Add return and inbound journey support
This commit is contained in:
parent
6ba71447ef
commit
9691632f65
12 changed files with 1687 additions and 486 deletions
|
|
@ -2,7 +2,7 @@
|
|||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>Plan your journey</h2>
|
||||
<form method="get" action="{{ url_for('search') }}">
|
||||
<form method="get" action="{{ url_for('search') }}" id="search-form">
|
||||
<div class="form-group-lg">
|
||||
<label for="station_crs" class="field-label">Departure point</label>
|
||||
<select id="station_crs" name="station_crs" class="form-control">
|
||||
|
|
@ -12,6 +12,33 @@
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="field-label">Journey type</span>
|
||||
<div class="destination-grid" role="radiogroup" aria-label="Journey type">
|
||||
<div class="destination-option">
|
||||
<input type='radio' id="journey-outbound" name="journey_type" value="outbound" checked>
|
||||
<label for="journey-outbound">
|
||||
<strong>Out</strong>
|
||||
<span>UK to Europe</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="destination-option">
|
||||
<input type='radio' id="journey-inbound" name="journey_type" value="inbound">
|
||||
<label for="journey-inbound">
|
||||
<strong>Back</strong>
|
||||
<span>Europe to UK</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="destination-option">
|
||||
<input type='radio' id="journey-return" name="journey_type" value="return">
|
||||
<label for="journey-return">
|
||||
<strong>Return</strong>
|
||||
<span>Out and back</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="field-label">Eurostar destination</span>
|
||||
<div class="destination-grid" role="radiogroup" aria-label="Eurostar destination">
|
||||
|
|
@ -36,13 +63,22 @@
|
|||
|
||||
<div class="form-group-lg">
|
||||
<label for="travel_date" class="field-label">
|
||||
Travel date
|
||||
Outbound / single date
|
||||
</label>
|
||||
<input type="date" id="travel_date" name="travel_date" required
|
||||
min="{{ today }}" value="{{ today }}"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group-lg">
|
||||
<label for="return_date" class="field-label">
|
||||
Return date
|
||||
</label>
|
||||
<input type="date" id="return_date" name="return_date"
|
||||
min="{{ today }}" value="{{ default_return_date }}"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="min_connection" class="field-label">
|
||||
Minimum connection time (Paddington → St Pancras)
|
||||
|
|
@ -69,5 +105,37 @@
|
|||
Search journeys
|
||||
</button>
|
||||
</form>
|
||||
<script>
|
||||
(function() {
|
||||
var form = document.getElementById('search-form');
|
||||
var returnDate = document.getElementById('return_date');
|
||||
var returnRadio = document.getElementById('journey-return');
|
||||
var journeyRadios = document.querySelectorAll('input[name="journey_type"]');
|
||||
var returnDateName = returnDate.name;
|
||||
|
||||
function currentJourneyType() {
|
||||
var checked = document.querySelector('input[name="journey_type"]:checked');
|
||||
return checked ? checked.value : 'outbound';
|
||||
}
|
||||
|
||||
function syncReturnDate() {
|
||||
returnDate.name = currentJourneyType() === 'return' ? returnDateName : '';
|
||||
}
|
||||
|
||||
returnDate.addEventListener('focus', function() {
|
||||
returnRadio.checked = true;
|
||||
syncReturnDate();
|
||||
});
|
||||
returnDate.addEventListener('change', function() {
|
||||
returnRadio.checked = true;
|
||||
syncReturnDate();
|
||||
});
|
||||
journeyRadios.forEach(function(radio) {
|
||||
radio.addEventListener('change', syncReturnDate);
|
||||
});
|
||||
form.addEventListener('submit', syncReturnDate);
|
||||
syncReturnDate();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{% 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 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 }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
|
||||
{% block twitter_title %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %}
|
||||
{% block twitter_title %}{{ self.title()|trim }}{% 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 %}
|
||||
|
||||
|
|
@ -12,13 +12,19 @@
|
|||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2>
|
||||
{{ departure_station_name }} → {{ destination }}
|
||||
{% if journey_type == 'inbound' %}
|
||||
{{ destination }} → {{ departure_station_name }}
|
||||
{% elif journey_type == 'return' %}
|
||||
{{ departure_station_name }} ↔ {{ destination }}
|
||||
{% else %}
|
||||
{{ departure_station_name }} → {{ destination }}
|
||||
{% endif %}
|
||||
</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, nr_class=url_nr_class, es_class=url_es_class) }}"
|
||||
<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) }}"
|
||||
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, nr_class=url_nr_class, es_class=url_es_class) }}"
|
||||
<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) }}"
|
||||
class="btn-nav">Next →</a>
|
||||
</div>
|
||||
<div class="switcher-section">
|
||||
|
|
@ -30,7 +36,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, nr_class=url_nr_class, es_class=url_es_class) }}"
|
||||
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) }}"
|
||||
>{{ destination_name }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
@ -57,36 +63,44 @@
|
|||
<div class="filter-row" style="margin-top:0.5rem">
|
||||
<div>
|
||||
<span class="filter-label">NR ticket:</span>
|
||||
<span id="nr-type-select" style="display:none"></span>
|
||||
<span style="display:none">Load advance prices</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>
|
||||
<button type="button" class="btn-group-option {% if nr_class == 'walkon' %}active{% endif %}" onclick="setNrClass('walkon')">Walk-on Std</button>
|
||||
<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">Loading…</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="filter-label">Eurostar:</span>
|
||||
<span id="es-type-select" style="display:none"></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>
|
||||
<button type="button" class="btn-group-option {% if es_class == 'standard' %}active{% endif %}" onclick="setEsClass('standard')">Standard</button>
|
||||
<button type="button" class="btn-group-option {% if es_class == 'plus' %}active{% endif %}" onclick="setEsClass('plus')">Standard Premier</button>
|
||||
</div>
|
||||
</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 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 ADVANCE_API_URLS = {{ advance_api_urls_json | safe }};
|
||||
let ADVANCE_STREAM_URLS = {{ advance_stream_urls_json | safe }};
|
||||
let cachedAdvanceFares = ADVANCE_FARES;
|
||||
let currentNrClass = '{{ nr_class }}';
|
||||
let currentEsClass = '{{ es_class }}';
|
||||
let advanceLoading = false;
|
||||
let advanceLoadingSections = {};
|
||||
|
||||
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);
|
||||
|
|
@ -104,11 +118,8 @@
|
|||
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();
|
||||
}
|
||||
if (cls === 'advance_std' || cls === 'advance_1st') loadMissingAdvanceFares();
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function setEsClass(cls) {
|
||||
|
|
@ -120,41 +131,6 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -165,52 +141,129 @@
|
|||
+ (fare.seats != null ? ' <span class="text-xs text-muted">' + fare.seats + ' at this price</span>' : '');
|
||||
}
|
||||
|
||||
function mergeAdvanceFares(sectionId, fares) {
|
||||
if (!ADVANCE_FARES) ADVANCE_FARES = {};
|
||||
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
|
||||
for (var time in fares) {
|
||||
if (!ADVANCE_FARES[sectionId][time]) {
|
||||
ADVANCE_FARES[sectionId][time] = {advance_std: null, advance_1st: null};
|
||||
}
|
||||
if (fares[time].advance_std) ADVANCE_FARES[sectionId][time].advance_std = fares[time].advance_std;
|
||||
if (fares[time].advance_1st) ADVANCE_FARES[sectionId][time].advance_1st = fares[time].advance_1st;
|
||||
}
|
||||
}
|
||||
|
||||
function sectionNeedsAdvance(sectionId) {
|
||||
for (var key in TRIP_FARES) {
|
||||
var row = TRIP_FARES[key];
|
||||
if (row.section !== sectionId || !row.advance_key) continue;
|
||||
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[sectionId];
|
||||
var advanceFares = sectionFares && sectionFares[row.advance_key];
|
||||
if (!advanceFares) return true;
|
||||
if (currentNrClass === 'advance_std' && !advanceFares.advance_std) return true;
|
||||
if (currentNrClass === 'advance_1st' && !advanceFares.advance_1st) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function loadAdvanceFaresForSection(sectionId) {
|
||||
if (advanceLoadingSections[sectionId] || !ADVANCE_API_URLS[sectionId]) return;
|
||||
advanceLoadingSections[sectionId] = true;
|
||||
if (!ADVANCE_FARES) ADVANCE_FARES = {};
|
||||
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
|
||||
|
||||
fetch(ADVANCE_API_URLS[sectionId])
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('advance fare request failed');
|
||||
return response.json();
|
||||
})
|
||||
.then(function(fares) {
|
||||
mergeAdvanceFares(sectionId, fares);
|
||||
})
|
||||
.catch(function() {})
|
||||
.finally(function() {
|
||||
advanceLoadingSections[sectionId] = false;
|
||||
updateDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
function loadAdvanceFaresForSectionStreaming(sectionId) {
|
||||
if (advanceLoadingSections[sectionId] || !ADVANCE_STREAM_URLS[sectionId]) return;
|
||||
advanceLoadingSections[sectionId] = true;
|
||||
if (!ADVANCE_FARES) ADVANCE_FARES = {};
|
||||
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
|
||||
|
||||
var source = new EventSource(ADVANCE_STREAM_URLS[sectionId]);
|
||||
source.onmessage = function(event) {
|
||||
var msg = JSON.parse(event.data);
|
||||
if (msg.type === 'fares') mergeAdvanceFares(sectionId, msg.fares);
|
||||
if (msg.type === 'done' || msg.type === 'error') {
|
||||
advanceLoadingSections[sectionId] = false;
|
||||
source.close();
|
||||
updateDisplay();
|
||||
}
|
||||
};
|
||||
source.onerror = function() {
|
||||
advanceLoadingSections[sectionId] = false;
|
||||
source.close();
|
||||
updateDisplay();
|
||||
};
|
||||
}
|
||||
|
||||
function loadMissingAdvanceFares() {
|
||||
for (var sectionId in ADVANCE_API_URLS) {
|
||||
if (sectionNeedsAdvance(sectionId)) loadAdvanceFaresForSection(sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
function currentNrFare(row) {
|
||||
if (currentNrClass === 'walkon') return row.walkon;
|
||||
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[row.section];
|
||||
var advFares = sectionFares && row.advance_key ? sectionFares[row.advance_key] : null;
|
||||
if (!advFares) return null;
|
||||
return currentNrClass === 'advance_std' ? advFares.advance_std : advFares.advance_1st;
|
||||
}
|
||||
|
||||
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];
|
||||
document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
|
||||
var key = tr.getAttribute('data-row-key');
|
||||
var row = TRIP_FARES[key];
|
||||
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 nrFare = currentNrFare(row);
|
||||
var esFare = currentEsClass === 'standard' ? row.es_standard : row.es_plus;
|
||||
if (nrFare && esFare) totals[stp] = nrFare.price + esFare.price + (row.circle_fare || 0);
|
||||
if (nrFare && esFare) totals[key] = 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];
|
||||
document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
|
||||
var key = tr.getAttribute('data-row-key');
|
||||
var row = TRIP_FARES[key];
|
||||
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 nrFare = currentNrFare(row);
|
||||
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');
|
||||
if (advStdEl || adv1stEl) {
|
||||
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[row.section];
|
||||
var advFares = sectionFares && row.advance_key ? sectionFares[row.advance_key] : null;
|
||||
if (advStdEl) {
|
||||
advStdEl.innerHTML = advFares && advFares.advance_std ? fareHtml(advFares.advance_std) : '';
|
||||
advStdEl.classList.toggle('fare-inactive', currentNrClass !== 'advance_std');
|
||||
}
|
||||
if (adv1stEl) {
|
||||
adv1stEl.innerHTML = advFares && advFares.advance_1st ? fareHtml(advFares.advance_1st) : '';
|
||||
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) {
|
||||
|
|
@ -222,44 +275,31 @@
|
|||
esPlusEl.classList.toggle('fare-inactive', currentEsClass !== 'plus');
|
||||
}
|
||||
|
||||
// Total
|
||||
var totalSpan = tr.querySelector('.total-price');
|
||||
if (totalSpan) {
|
||||
if (stp in totals) {
|
||||
var total = totals[stp];
|
||||
if (key in totals) {
|
||||
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">\uD83E\uDE99</span>';
|
||||
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">\uD83D\uDCB8</span>';
|
||||
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">low</span>';
|
||||
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">high</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() {
|
||||
if (currentNrClass === 'advance_std' || currentNrClass === 'advance_1st') loadMissingAdvanceFares();
|
||||
updateDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
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 }}
|
||||
{{ gwr_count }} National Rail service{{ 's' if gwr_count != 1 }}
|
||||
·
|
||||
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
|
||||
{% if from_cache %}
|
||||
|
|
@ -278,135 +318,180 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if trips or unreachable_morning_services %}
|
||||
<div class="card">
|
||||
<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 }}" data-stp="{{ row.depart_st_pancras }}">
|
||||
{% 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 %}
|
||||
<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>
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
<br><span class="total-price"></span>
|
||||
</td>
|
||||
{% if sections %}
|
||||
{% for section in sections %}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2>
|
||||
{% if section.direction == 'inbound' %}
|
||||
Return: {{ destination }} → {{ departure_station_name }}
|
||||
{% 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 %}
|
||||
<span class="fare-line es-standard"></span>
|
||||
<span class="fare-line es-plus"></span>
|
||||
</td>
|
||||
<td class="text-dimmed">—</td>
|
||||
Outbound: {{ departure_station_name }} → {{ destination }}
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</h2>
|
||||
<p class="card-meta">{{ section.date_display }}</p>
|
||||
{% if section.rows %}
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if section.direction == 'inbound' %}
|
||||
<th>Eurostar<br><span class="text-xs font-normal text-muted">{{ destination }} → St Pancras</span></th>
|
||||
<th class="col-transfer">Transfer<br><span class="text-xs font-normal text-muted">St Pancras → Paddington</span></th>
|
||||
<th>National Rail<br><span class="text-xs font-normal text-muted">Paddington → {{ departure_station_name }}</span></th>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
<th class="nowrap">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set trip_rows = section.rows | selectattr('row_type', 'equalto', 'trip') | list %}
|
||||
{% if trip_rows %}
|
||||
{% set best_mins = trip_rows | map(attribute='total_minutes') | min %}
|
||||
{% set worst_mins = trip_rows | map(attribute='total_minutes') | max %}
|
||||
{% endif %}
|
||||
{% for row in section.rows %}
|
||||
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
|
||||
{% set row_class = 'row-fast' %}
|
||||
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trip_rows | 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 }}" data-row-key="{{ row.row_key }}"
|
||||
{% if row.ticket_price is not none %}data-walkon="{{ row.ticket_price }}"{% endif %}
|
||||
{% if row.eurostar_price is not none %}data-es-std="{{ row.eurostar_price }}"{% endif %}>
|
||||
{% if row.row_type == 'trip' %}
|
||||
{% if section.direction == 'inbound' %}
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_destination }} → {{ row.arrive_st_pancras }} <span class="font-normal text-muted" style="font-size:0.85em">(UK)</span></span>
|
||||
<br><span class="text-xs text-muted">check in by {{ row.check_in_by }}</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 %}{{ row.train_number }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard">{% if row.eurostar_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
|
||||
<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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_paddington }} → {{ row.arrive_uk_station }}</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">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
|
||||
{% endif %}
|
||||
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line nr-advance-std"></span>
|
||||
<span class="fare-line nr-advance-1st"></span>
|
||||
</td>
|
||||
{% else %}
|
||||
<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">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
|
||||
{% endif %}
|
||||
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line nr-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>
|
||||
{% 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>
|
||||
{% 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 %}{{ row.train_number }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard">{% if row.eurostar_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
|
||||
<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>
|
||||
{% endif %}
|
||||
<td class="font-bold nowrap">
|
||||
{% if row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
|
||||
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
|
||||
{% elif row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %}
|
||||
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
|
||||
{% else %}
|
||||
<span class="text-blue">{{ row.total_duration }}</span>
|
||||
{% endif %}
|
||||
<br><span class="total-price"></span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
<span class="text-dimmed text-sm">Too early</span>
|
||||
</td>
|
||||
<td class="col-transfer text-dimmed">—</td>
|
||||
<td>
|
||||
{% if section.direction == 'inbound' %}
|
||||
<span class="font-bold nowrap text-dimmed">{{ row.depart_destination }} → {{ row.arrive_st_pancras }}</span>
|
||||
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="font-bold nowrap text-dimmed">{{ row.depart_st_pancras }} → {{ row.arrive_destination }}</span>
|
||||
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-dimmed nowrap">No connection</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No valid journeys found.</p>
|
||||
<p>
|
||||
{% if section.gwr_count == 0 and section.eurostar_count == 0 %}
|
||||
Could not retrieve train data. Check your network connection or try again.
|
||||
{% elif section.gwr_count == 0 %}
|
||||
No National Rail trains found for this date.
|
||||
{% elif section.eurostar_count == 0 %}
|
||||
No Eurostar services found for {{ destination }} on this date.
|
||||
{% else %}
|
||||
No National Rail + Eurostar combination has a {{ section.min_connection }}-{{ section.max_connection }} minute connection.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<p class="footnote">
|
||||
Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
||||
GWR walk-on and advance prices from
|
||||
<a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
|
||||
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>
|
||||
·
|
||||
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
|
||||
</p>
|
||||
|
||||
{% 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 class="footnote">
|
||||
Connection windows:
|
||||
{% for section in sections %}
|
||||
{% if section.direction == 'inbound' %}return{% else %}outbound{% endif %}
|
||||
{{ section.min_connection }}–{{ section.max_connection }} min{% if not loop.last %}; {% endif %}
|
||||
{% endfor %}.
|
||||
National Rail prices from <a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
|
||||
Eurostar prices are for 1 adult in GBP; return searches use Eurostar return-search prices.
|
||||
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 }} on RTT</a>
|
||||
·
|
||||
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington on RTT</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue