Five UI and data features for return journeys and results page

- Replace native date inputs with always-open custom calendar; return
  journeys show two months side-by-side with Airbnb-style range selection
- Add min-connection filter (30/40/50/60 min) for the inbound leg of
  return journeys, separate from the outbound connection filter
- Fix total journey time: naive datetime subtraction across CET/BST was
  1 h too long outbound and 1 h too short inbound
- Filter inbound circle line suggestions when connection ≥ 40 min: only
  show services arriving ≥ 5 min before GWR departure at Paddington
- Add Std / SP labels to Eurostar fare lines so users can distinguish
  Standard from Standard Premier
- Row selection with a fixed summary bar showing NR + Eurostar + circle
  totals; selection is preserved in the URL
- Load walk-on fares sequentially, outbound section first
- Mobile: card-grid table layout, hide headcode/platform on small screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-05-25 14:58:32 +01:00
parent a88b19fa4c
commit 1bc7631863
5 changed files with 695 additions and 122 deletions

13
app.py
View file

@ -90,6 +90,7 @@ def index():
VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120}
VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180}
VALID_INBOUND_MIN_CONNECTIONS = {20, 30, 40, 45, 50, 60, 70, 80, 90, 100, 110, 120}
VALID_INBOUND_RETURN_MIN_CONNECTIONS = {30, 40, 50, 60}
VALID_INBOUND_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180}
VALID_JOURNEY_TYPES = {"outbound", "inbound", "return"}
VALID_NR_CLASSES = {'walkon', 'advance_std', 'advance_1st'}
@ -357,6 +358,7 @@ def _results(station_crs, slug, travel_date, journey_type, return_date):
if es_class not in VALID_ES_CLASSES:
es_class = DEFAULT_ES_CLASS
inbound_min_connection = INBOUND_MIN_CONNECTION_MINUTES
if journey_type == "return":
def _p(raw, default, valid):
return raw if raw in valid else default
@ -364,6 +366,11 @@ def _results(station_crs, slug, travel_date, journey_type, return_date):
nr_class_in = _p(request.args.get("nr_class_in"), DEFAULT_NR_CLASS, VALID_NR_CLASSES)
es_class_out = _p(request.args.get("es_class_out"), DEFAULT_ES_CLASS, VALID_ES_CLASSES)
es_class_in = _p(request.args.get("es_class_in"), DEFAULT_ES_CLASS, VALID_ES_CLASSES)
inbound_min_connection = _parse_connection(
request.args.get("min_connection_in"),
INBOUND_MIN_CONNECTION_MINUTES,
VALID_INBOUND_RETURN_MIN_CONNECTIONS,
)
else:
nr_class_out = nr_class_in = nr_class
es_class_out = es_class_in = es_class
@ -464,7 +471,7 @@ def _results(station_crs, slug, travel_date, journey_type, return_date):
section_min_connection = min_connection
section_max_connection = max_connection
if journey_type == "return" and direction == "inbound":
section_min_connection = INBOUND_MIN_CONNECTION_MINUTES
section_min_connection = inbound_min_connection
section_max_connection = INBOUND_MAX_CONNECTION_MINUTES
rtt_direction = "to_paddington" if direction == "outbound" else "from_paddington"
rtt_cache_key = _nr_exact_cache_key(rtt_direction, station_crs, section_date)
@ -672,6 +679,7 @@ def _results(station_crs, slug, travel_date, journey_type, return_date):
"return_date": return_date,
"min_connection": url_min,
"max_connection": url_max,
"min_connection_in": None if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES else inbound_min_connection,
"nr_class_out": None if nr_class_out == DEFAULT_NR_CLASS else nr_class_out,
"nr_class_in": None if nr_class_in == DEFAULT_NR_CLASS else nr_class_in,
"es_class_out": None if es_class_out == DEFAULT_ES_CLASS else es_class_out,
@ -860,6 +868,9 @@ def _results(station_crs, slug, travel_date, journey_type, return_date):
advance_fares_stream_url=url_for("api_advance_fares_stream", station_crs=station_crs, travel_date=travel_date),
valid_min_connections=sorted(valid_min),
valid_max_connections=sorted(valid_max),
inbound_min_connection=inbound_min_connection,
default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES,
valid_inbound_return_min_connections=sorted(VALID_INBOUND_RETURN_MIN_CONNECTIONS),
)

View file

@ -182,10 +182,47 @@
}
@media (max-width: 640px) {
.card {
padding: 1.25rem;
.card { padding: 1.25rem; }
/* Convert results table to a 2-column card layout per row */
.results-table, .results-table tbody { display: block; }
.results-table thead { display: none; }
.results-table tr {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 2px solid #e2e8f0;
padding: 0.1rem 0;
}
.col-transfer { display: none; }
.results-table td { padding: 0.35rem 0.45rem; font-size: 0.8rem; border-bottom: none; }
/* First journey leg (NR outbound / Eurostar inbound) */
.results-table td:nth-child(1) { grid-column: 1; grid-row: 1; }
/* Transfer column: hidden */
.col-transfer { display: none !important; }
/* Second journey leg */
.results-table td:nth-child(3) { grid-column: 2; grid-row: 1; }
/* Total: spans both columns, right-aligned */
.results-table td:nth-child(4) {
grid-column: 1 / -1; grid-row: 2;
text-align: right;
border-top: 1px solid #e2e8f0;
padding: 0.25rem 0.45rem 0.3rem;
}
/* Hide non-essential detail on mobile */
.mobile-hide { display: none !important; }
.fare-seats { display: none !important; }
/* Show connection time hint */
.mobile-conn { display: block !important; }
/* Flow arrow: hide on mobile */
.results-table thead th.flow-step::after { display: none; }
.results-table thead th.flow-step { padding-right: 0; }
/* Selection bar: smaller on mobile */
#selection-bar { padding: 0.5rem 0.75rem; font-size: 0.8rem; }
.sel-totals { gap: 0.75rem; }
}
a { color: #00539f; }
@ -277,6 +314,41 @@
.row-slow { background: #fff5f5; }
.row-alt { background: #f7fafc; }
.row-unreachable { background: #f7fafc; color: #a0aec0; }
.row-selected { background: #ebf8ff !important; }
tr.row-selectable { cursor: pointer; }
tr.row-selectable:hover:not(.row-selected) { filter: brightness(0.97); }
/* Journey flow arrow between column headers */
.results-table thead th.flow-step { position: relative; padding-right: 1.4rem; }
.results-table thead th.flow-step::after {
content: '';
position: absolute; right: 0.2rem; top: 50%; transform: translateY(-50%);
color: #cbd5e0; font-size: 1.5rem; font-weight: 300; line-height: 1;
}
/* Mobile: hidden by default, shown on mobile */
.mobile-conn { display: none; }
.fare-seats { display: inline; }
/* Selection summary bar */
#selection-bar {
display: none; position: fixed; bottom: 0; left: 0; right: 0;
background: #fff; border-top: 2px solid #00539f;
padding: 0.65rem 1rem;
box-shadow: 0 -2px 10px rgba(0,0,0,0.12); z-index: 200;
font-size: 0.88rem;
}
.sel-bar-inner {
max-width: 1100px; margin: 0 auto;
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 0.5rem;
}
.sel-totals { display: flex; gap: 1.25rem; align-items: center; flex-wrap: wrap; }
.sel-clear {
background: none; border: 1px solid #cbd5e0; border-radius: 4px;
padding: 0.2rem 0.6rem; font-size: 0.8rem; cursor: pointer; color: #718096;
}
.sel-clear:hover { background: #f0f4f8; }
/* Empty state */
.empty-state { color: #4a5568; text-align: center; padding: 3rem 2rem; }

View file

@ -1,8 +1,75 @@
{% extends "base.html" %}
{% block content %}
<style>
/* ── Calendar ───────────────────────────────────────────────────── */
.cal-wrap { margin-top: .5rem; }
.cal-nav {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: .9rem;
}
.cal-nav-btn {
flex-shrink: 0; width: 2rem; height: 2rem;
border: 1px solid #e2e8f0; border-radius: 50%;
background: #fff; cursor: pointer;
font-size: 1.3rem; line-height: 1; color: #4a5568;
display: flex; align-items: center; justify-content: center;
transition: background .12s;
}
.cal-nav-btn:hover:not(:disabled) { background: #f0f4f8; }
.cal-nav-btn:disabled { opacity: .3; cursor: default; }
#cal-titles {
display: flex; flex: 1; gap: 0;
margin: 0 .5rem;
font-weight: 600; font-size: .9rem; color: #1a202c;
}
#cal-titles span { flex: 1; text-align: center; }
#cal-months { display: flex; gap: 2rem; }
.cal-month { flex: 1; min-width: 0; }
.cal-grid {
display: grid; grid-template-columns: repeat(7, 1fr);
}
.cal-dow {
text-align: center; font-size: .72rem; font-weight: 600;
color: #718096; padding: .4rem 0 .25rem;
}
.cal-cell { padding: 1px 0; } /* background set inline for range */
.cal-btn {
width: 100%; aspect-ratio: 1;
max-width: 2.4rem; max-height: 2.4rem;
border: none; background: none; cursor: pointer;
font-size: .875rem; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
margin: 0 auto;
transition: background .1s;
font-family: inherit; color: #1a202c;
}
.cal-btn:hover:not(:disabled):not(.cal-selected) { background: #e2e8f0; }
.cal-btn.cal-past { color: #d1d5db !important; cursor: default; }
.cal-btn.cal-today { color: #00539f; font-weight: 700; }
.cal-btn.cal-selected {
background: #00539f !important; color: #fff !important; font-weight: 600;
}
/* subtly darken the in-range hover */
.cal-cell.cal-in-range .cal-btn:hover:not(:disabled) { background: #bfdbfe; }
.cal-hint { margin-top: .75rem; font-size: .88rem; color: #4a5568; min-height: 1.4rem; }
.cal-cta { color: #00539f; font-weight: 600; }
@media (max-width: 620px) {
#cal-months { flex-direction: column; gap: 1.25rem; }
#cal-titles { gap: 0; }
}
</style>
<div class="card">
<h2>Plan your journey</h2>
<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">
@ -16,25 +83,16 @@
<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>
<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>
<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>
<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>
@ -43,40 +101,31 @@
<span class="field-label">Eurostar destination</span>
<div class="destination-grid" role="radiogroup" aria-label="Eurostar destination">
{% for slug, name in destinations.items() %}
{% set city = name.replace(' Gare du Nord', '').replace(' Centraal', '').replace(' Midi', '').replace(' Europe', '') %}
{% set city = name.replace(' Gare du Nord','').replace(' Centraal','').replace(' Midi','').replace(' Europe','') %}
<div class="destination-option">
<input
type="radio"
id="destination-{{ slug }}"
name="destination"
value="{{ slug }}"
{% if loop.first %}checked{% endif %}
required>
<label for="destination-{{ slug }}">
<strong>{{ city }}</strong>
<span>{{ name }}</span>
</label>
<input type="radio" id="dest-{{ slug }}" name="destination" value="{{ slug }}"
{% if loop.first %}checked{% endif %} required>
<label for="dest-{{ slug }}"><strong>{{ city }}</strong><span>{{ name }}</span></label>
</div>
{% endfor %}
</div>
</div>
<!-- ── Calendar date picker ──────────────────────────────────────── -->
<div class="form-group-lg">
<label for="travel_date" class="field-label">
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" id="return-date-group">
<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" disabled>
<span class="field-label">Travel dates</span>
<div class="cal-wrap">
<div class="cal-nav">
<button type="button" id="cal-prev" class="cal-nav-btn" aria-label="Previous month">&#8249;</button>
<div id="cal-titles"></div>
<button type="button" id="cal-next" class="cal-nav-btn" aria-label="Next month">&#8250;</button>
</div>
<div id="cal-months"></div>
<div id="cal-hint" class="cal-hint"></div>
</div>
<!-- Hidden inputs submitted with the form -->
<input type="hidden" id="travel_date" name="travel_date">
<input type="hidden" id="return_date" name="">
</div>
<div class="form-group">
@ -101,60 +150,330 @@
</select>
</div>
<button type="submit" class="btn-primary">
Search journeys
</button>
<button type="submit" class="btn-primary">Search journeys</button>
</form>
<script>
(function() {
var form = document.getElementById('search-form');
var travelDate = document.getElementById('travel_date');
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 () {
'use strict';
function currentJourneyType() {
var checked = document.querySelector('input[name="journey_type"]:checked');
return checked ? checked.value : 'outbound';
const TODAY = new Date('{{ today }}T00:00:00');
/* ── state ───────────────────────────────────────────────────────── */
let viewYear = TODAY.getFullYear();
let viewMonth = TODAY.getMonth(); // 0-based
let outDate = new Date(TODAY); // default: today selected
let retDate = null;
let hoverDate = null;
let isReturn = false;
let retPhase = false; // true while waiting for return-date click
/* ── helpers ─────────────────────────────────────────────────────── */
function toYMD(d) {
if (!d) return '';
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function dispDate(d) {
return d ? d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : '';
}
function sameDay(a, b) {
return a && b && a.toDateString() === b.toDateString();
}
function advMonth(y, m, delta) {
m += delta;
while (m < 0) { m += 12; y--; }
while (m > 11) { m -= 12; y++; }
return [y, m];
}
/* ── sync hidden inputs ──────────────────────────────────────────── */
function syncInputs() {
document.getElementById('travel_date').value = toYMD(outDate);
var ri = document.getElementById('return_date');
if (isReturn && retDate) {
ri.value = toYMD(retDate);
ri.name = 'return_date';
} else {
ri.value = '';
ri.name = '';
}
}
function syncReturnDate() {
var isReturn = currentJourneyType() === 'return';
returnDate.name = isReturn ? returnDateName : '';
returnDate.disabled = !isReturn;
}
/* ── day click ───────────────────────────────────────────────────── */
function updateReturnMin() {
if (travelDate.value) {
returnDate.min = travelDate.value;
if (returnDate.value < travelDate.value) {
returnDate.value = travelDate.value;
}
function onDayClick(d) {
if (!isReturn) {
outDate = d;
} else if (!outDate || !retPhase) {
/* start fresh: set outbound, await return */
outDate = d;
retDate = null;
retPhase = true;
} else {
/* selecting return date */
if (sameDay(d, outDate)) {
/* tapped same day → reset */
outDate = null;
retDate = null;
retPhase = false;
} else if (d < outDate) {
/* earlier than outbound → new outbound, keep retPhase */
outDate = d;
} else {
retDate = d;
retPhase = false;
}
}
hoverDate = null;
syncInputs();
render();
}
// Clicking anywhere in the return date group (including label/disabled input)
// activates return journey type and enables the field
document.getElementById('return-date-group').addEventListener('click', function() {
if (returnDate.disabled) {
returnRadio.checked = true;
syncReturnDate();
returnDate.focus();
/* ── render ──────────────────────────────────────────────────────── */
function render() {
var fmt = { month: 'long', year: 'numeric' };
var t0 = new Date(viewYear, viewMonth, 1).toLocaleDateString('en-GB', fmt);
var titleHtml = '<span>' + t0 + '</span>';
if (isReturn) {
var next = advMonth(viewYear, viewMonth, 1);
var t1 = new Date(next[0], next[1], 1).toLocaleDateString('en-GB', fmt);
titleHtml += '<span>' + t1 + '</span>';
}
document.getElementById('cal-titles').innerHTML = titleHtml;
document.getElementById('cal-prev').disabled =
(viewYear === TODAY.getFullYear() && viewMonth === TODAY.getMonth());
renderMonths();
renderHint();
}
function renderMonths() {
var cont = document.getElementById('cal-months');
cont.innerHTML = '';
var n = isReturn ? 2 : 1;
for (var i = 0; i < n; i++) {
var ym = advMonth(viewYear, viewMonth, i);
cont.appendChild(buildMonth(ym[0], ym[1]));
}
}
function buildMonth(year, month) {
/* effective range (includes hover preview) */
var rangeA = null, rangeB = null;
if (outDate && retDate) {
rangeA = outDate < retDate ? outDate : retDate;
rangeB = outDate < retDate ? retDate : outDate;
} else if (outDate && retPhase && hoverDate && hoverDate > outDate) {
rangeA = outDate;
rangeB = hoverDate;
}
var wrap = document.createElement('div');
wrap.className = 'cal-month';
var grid = document.createElement('div');
grid.className = 'cal-grid';
/* day-of-week headers, Mon first */
['Mo','Tu','We','Th','Fr','Sa','Su'].forEach(function (lbl) {
var el = document.createElement('div');
el.className = 'cal-dow';
el.textContent = lbl;
grid.appendChild(el);
});
/* offset: Mon=0 … Sun=6 */
var firstDay = new Date(year, month, 1);
var offset = firstDay.getDay() - 1;
if (offset < 0) offset = 6;
var lastDate = new Date(year, month + 1, 0).getDate();
/* leading empty cells */
for (var p = 0; p < offset; p++) {
grid.appendChild(Object.assign(document.createElement('div'), { className: 'cal-cell' }));
}
/* day cells */
for (var d = 1; d <= lastDate; d++) {
var date = new Date(year, month, d);
var past = date < TODAY;
var isTod = sameDay(date, TODAY);
var isOut = sameDay(date, outDate);
var isRet = sameDay(date, retDate);
var col = (d - 1 + offset) % 7; /* 0=Mon, 6=Sun */
var inRange = rangeA && rangeB && date > rangeA && date < rangeB;
var isRA = rangeA && sameDay(date, rangeA);
var isRB = rangeB && sameDay(date, rangeB);
var cell = document.createElement('div');
cell.className = 'cal-cell' + (inRange ? ' cal-in-range' : '');
/* range background: gradient at endpoints, solid between */
if (isRA && rangeB) {
cell.style.background = col < 6
? 'linear-gradient(to right,transparent 50%,#dbeafe 50%)'
: 'transparent'; /* Sunday: no gradient needed */
} else if (isRB && rangeA) {
cell.style.background = col > 0
? 'linear-gradient(to left,transparent 50%,#dbeafe 50%)'
: 'transparent'; /* Monday: no gradient needed */
} else if (inRange) {
cell.style.background = '#dbeafe';
}
var btn = document.createElement('button');
btn.type = 'button';
var cls = 'cal-btn';
if (past) cls += ' cal-past';
else if (isTod && !isOut && !isRet) cls += ' cal-today';
if (isOut || isRet) cls += ' cal-selected';
btn.className = cls;
btn.textContent = d;
btn.disabled = past;
btn.setAttribute('aria-label',
date.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }));
cell.setAttribute('data-date', toYMD(date));
cell.setAttribute('data-col', col);
if (!past) {
(function (dt) {
btn.addEventListener('click', function () { onDayClick(dt); });
btn.addEventListener('mouseenter', function () {
if (isReturn && retPhase && (!hoverDate || !sameDay(hoverDate, dt))) {
hoverDate = dt;
applyHoverStyles(outDate, dt);
}
});
})(new Date(date));
}
cell.appendChild(btn);
grid.appendChild(cell);
}
grid.addEventListener('mouseleave', function () {
if (hoverDate) {
hoverDate = null;
applyHoverStyles(
outDate && retDate ? (outDate < retDate ? outDate : retDate) : null,
outDate && retDate ? (outDate < retDate ? retDate : outDate) : null
);
}
});
travelDate.addEventListener('change', updateReturnMin);
wrap.appendChild(grid);
return wrap;
}
journeyRadios.forEach(function(radio) {
radio.addEventListener('change', syncReturnDate);
/* ── hover range: update cell styles in-place (no DOM rebuild) ───── */
function applyHoverStyles(rangeA, rangeB) {
document.querySelectorAll('.cal-cell[data-date]').forEach(function (cell) {
var d = new Date(cell.getAttribute('data-date') + 'T00:00:00');
var col = parseInt(cell.getAttribute('data-col'), 10);
var inRange = rangeA && rangeB && d > rangeA && d < rangeB;
var isRA = rangeA && rangeB && sameDay(d, rangeA);
var isRB = rangeA && rangeB && sameDay(d, rangeB);
cell.classList.toggle('cal-in-range', !!inRange);
if (isRA) {
cell.style.background = col < 6
? 'linear-gradient(to right,transparent 50%,#dbeafe 50%)' : '';
} else if (isRB) {
cell.style.background = col > 0
? 'linear-gradient(to left,transparent 50%,#dbeafe 50%)' : '';
} else if (inRange) {
cell.style.background = '#dbeafe';
} else {
cell.style.background = '';
}
});
}
form.addEventListener('submit', syncReturnDate);
function renderHint() {
var el = document.getElementById('cal-hint');
if (!el) return;
if (!isReturn) {
el.innerHTML = outDate
? 'Date: <strong>' + dispDate(outDate) + '</strong>'
: '<span class="cal-cta">Select a travel date</span>';
} else if (!outDate) {
el.innerHTML = '<span class="cal-cta">Select departure date</span>';
} else if (retPhase) {
el.innerHTML = 'Depart: <strong>' + dispDate(outDate) +
'</strong> &nbsp;&middot;&nbsp; <span class="cal-cta">Now select return date</span>';
} else {
el.innerHTML = 'Depart: <strong>' + dispDate(outDate) +
'</strong> &nbsp;&middot;&nbsp; Return: <strong>' + dispDate(retDate) + '</strong>';
}
}
updateReturnMin();
syncReturnDate();
})();
/* ── navigation ──────────────────────────────────────────────────── */
document.getElementById('cal-prev').addEventListener('click', function () {
var ym = advMonth(viewYear, viewMonth, -1);
viewYear = ym[0]; viewMonth = ym[1];
render();
});
document.getElementById('cal-next').addEventListener('click', function () {
var ym = advMonth(viewYear, viewMonth, 1);
viewYear = ym[0]; viewMonth = ym[1];
render();
});
/* ── journey type ────────────────────────────────────────────────── */
document.querySelectorAll('input[name="journey_type"]').forEach(function (r) {
r.addEventListener('change', function () {
var wasReturn = isReturn;
isReturn = this.value === 'return';
if (isReturn && !wasReturn) {
retDate = null;
retPhase = outDate !== null;
} else if (!isReturn) {
retDate = null;
retPhase = false;
}
syncInputs();
render();
});
});
/* ── form submit ─────────────────────────────────────────────────── */
document.getElementById('search-form').addEventListener('submit', function (e) {
if (!outDate) {
e.preventDefault();
alert('Please select a travel date.');
return;
}
if (isReturn && !retDate) {
e.preventDefault();
alert('Please select a return date.');
return;
}
syncInputs();
});
/* ── init ────────────────────────────────────────────────────────── */
var initType = document.querySelector('input[name="journey_type"]:checked');
isReturn = initType && initType.value === 'return';
retPhase = isReturn && outDate !== null;
syncInputs();
render();
}());
</script>
</div>
{% endblock %}

View file

@ -79,6 +79,16 @@
{% for section in sections %}
<div class="filter-row" style="margin-top:0.5rem">
<span class="filter-label" style="min-width:5.5rem">{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}:</span>
{% if section.direction == 'inbound' %}
<div>
<label for="min_conn_in_select" class="filter-label">Min connection:</label>
<select id="min_conn_in_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_inbound_return_min_connections %}
<option value="{{ mins }}" {% if mins == inbound_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<span class="filter-label">NR:</span>
<div class="btn-group">
@ -129,6 +139,7 @@
const RESULTS_BASE = '{{ results_base_url }}';
const DEFAULT_MIN_CONN = {{ default_min_connection }};
const DEFAULT_MAX_CONN = {{ default_max_connection }};
const DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }};
let TRIP_FARES = {{ trip_fares_json | safe }};
let ADVANCE_FARES = {{ advance_fares_json | safe }};
let WALKON_API_URLS = {{ walkon_api_urls_json | safe }};
@ -141,6 +152,8 @@
let currentEsClasses = {{ es_classes_json | safe }};
const SECTION_DIRECTIONS = {{ section_directions_json | safe }};
let advanceLoadingSections = {};
let walkonLoadingSections = {};
let selectedRowKeys = {};
function updateAdvanceLoadingStatus() {
var loading = Object.keys(advanceLoadingSections).some(function(sectionId) {
@ -156,6 +169,11 @@
var params = [];
if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min);
if (max !== DEFAULT_MAX_CONN) params.push('max_connection=' + max);
var minInEl = document.getElementById('min_conn_in_select');
if (minInEl) {
var minIn = parseInt(minInEl.value);
if (minIn !== DEFAULT_MIN_CONN_IN) params.push('min_connection_in=' + minIn);
}
var sectionIds = Object.keys(currentNrClasses);
if (sectionIds.length === 1) {
var nrCls = currentNrClasses[sectionIds[0]];
@ -172,6 +190,12 @@
if (esC !== 'standard') params.push('es_class' + suffix + '=' + esC);
});
}
for (var _sid in selectedRowKeys) {
var _sel = selectedRowKeys[_sid];
if (!_sel || !SECTION_DIRECTIONS[_sid]) continue;
var _pname = SECTION_DIRECTIONS[_sid] === 'outbound' ? 'out' : 'ret';
params.push(_pname + '=' + encodeURIComponent(_sel.slice(_sid.length + 1)));
}
return params.length ? RESULTS_BASE + '?' + params.join('&') : RESULTS_BASE;
}
@ -205,7 +229,7 @@
function fareHtml(fare) {
return '<span class="text-sm font-bold">' + fmtPrice(fare.price) + '</span>'
+ (fare.ticket ? ' <span class="text-xs text-muted">' + fare.ticket + '</span>' : '')
+ (fare.seats != null ? ' <span class="text-xs text-muted">' + fare.seats + ' at this price</span>' : '');
+ (fare.seats != null ? ' <span class="text-xs text-muted fare-seats">' + fare.seats + ' at this price</span>' : '');
}
function mergeAdvanceFares(sectionId, fares) {
@ -376,17 +400,19 @@
var esStdEl = tr.querySelector('.es-standard');
var esPlusEl = tr.querySelector('.es-plus');
if (esStdEl) {
esStdEl.innerHTML = row.es_standard ? fareHtml(row.es_standard) : '<span class="text-sm text-muted">\u2013</span>';
esStdEl.innerHTML = '<span class="text-xs text-muted">Std</span> ' + (row.es_standard ? fareHtml(row.es_standard) : '<span class="text-sm text-muted">\u2013</span>');
esStdEl.classList.toggle('fare-inactive', esClass !== 'standard');
}
if (esPlusEl) {
esPlusEl.innerHTML = row.es_plus ? fareHtml(row.es_plus) : '<span class="text-sm text-muted">\u2013</span>';
esPlusEl.innerHTML = '<span class="text-xs text-muted">SP</span> ' + (row.es_plus ? fareHtml(row.es_plus) : '<span class="text-sm text-muted">\u2013</span>');
esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus');
}
var totalSpan = tr.querySelector('.total-price');
if (totalSpan) {
if (key in totals) {
if (key.indexOf(':unreachable:') !== -1) {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="No National Rail service from {{ departure_station_name }} arrives at Paddington in time for this Eurostar">No rail connection</span>';
} else 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) {
@ -395,42 +421,153 @@
}
html += '</span>';
totalSpan.innerHTML = html;
} else if (!nrFare && walkonLoadingSections[row.section]) {
totalSpan.innerHTML = '';
} else if (!nrFare) {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="National Rail fare data is not available for this service">NR fare not available</span>';
} else {
var missing = !nrFare ? 'No NR fare' : 'No Eurostar fare';
totalSpan.innerHTML = '<span class="text-xs text-muted">' + missing + '</span>';
totalSpan.innerHTML = '<span class="text-xs text-muted" title="No Eurostar price found for this service — it may be sold out in the selected class. Check eurostar.com.">No Eurostar price</span>';
}
}
});
updateSelectionBar();
}
function loadWalkonFares() {
var urls = WALKON_API_URLS;
var pending = Object.keys(urls).length;
if (!pending) return;
function done() {
var ids = Object.keys(urls);
if (!ids.length) return;
/* outbound first, then inbound */
ids.sort(function(a, b) {
return (SECTION_DIRECTIONS[a] === 'outbound' ? 0 : 1) -
(SECTION_DIRECTIONS[b] === 'outbound' ? 0 : 1);
});
ids.forEach(function(sid) { walkonLoadingSections[sid] = true; });
var pending = ids.length;
function done(id) {
walkonLoadingSections[id] = false;
if (--pending === 0) {
var el = document.getElementById('walkon-loading');
if (el) el.style.display = 'none';
}
}
for (var sectionId in urls) {
(function(id, url) {
fetch(url).then(function(r) { return r.json(); }).then(function(fares) {
mergeWalkonFares(id, fares);
updateDisplay();
done();
}).catch(done);
})(sectionId, urls[sectionId]);
/* sequential: each fetch starts only after the previous one finishes */
ids.reduce(function(chain, id) {
return chain.then(function() {
return fetch(urls[id])
.then(function(r) { return r.json(); })
.then(function(fares) { mergeWalkonFares(id, fares); done(id); updateDisplay(); })
.catch(function() { done(id); updateDisplay(); });
});
}, Promise.resolve());
}
function initSelectionFromUrl() {
var params = new URLSearchParams(window.location.search);
for (var sid in SECTION_DIRECTIONS) {
var dir = SECTION_DIRECTIONS[sid];
var val = params.get(dir === 'outbound' ? 'out' : 'ret');
if (val) {
var rowKey = sid + ':' + val;
if (TRIP_FARES[rowKey]) selectedRowKeys[sid] = rowKey;
}
}
}
function selectRow(tr) {
var key = tr.getAttribute('data-row-key');
if (!key || key.indexOf(':unreachable:') !== -1) return;
var row = TRIP_FARES[key];
if (!row) return;
selectedRowKeys[row.section] = (selectedRowKeys[row.section] === key) ? null : key;
updateRowHighlights();
updateSelectionBar();
history.replaceState(null, '', buildUrl());
}
function clearSelection() {
selectedRowKeys = {};
updateRowHighlights();
updateSelectionBar();
history.replaceState(null, '', buildUrl());
}
function updateRowHighlights() {
document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
var key = tr.getAttribute('data-row-key');
var row = TRIP_FARES[key];
if (!row) return;
tr.classList.toggle('row-selected', selectedRowKeys[row.section] === key);
});
}
function updateSelectionBar() {
var bar = document.getElementById('selection-bar');
if (!bar) return;
var allSids = Object.keys(SECTION_DIRECTIONS);
var activeSids = allSids.filter(function(sid) { return selectedRowKeys[sid]; });
if (activeSids.length === 0) { bar.style.display = 'none'; return; }
bar.style.display = 'block';
var totalNr = 0, totalEs = 0, totalCircle = 0, allPrices = true;
var parts = [];
activeSids.forEach(function(sid) {
var rowKey = selectedRowKeys[sid];
var row = TRIP_FARES[rowKey];
if (!row) return;
var nrFare = currentNrFare(row);
var esClass = currentEsClasses[sid] || 'standard';
var esFare = esClass === 'standard' ? row.es_standard : row.es_plus;
if (nrFare) totalNr += nrFare.price; else allPrices = false;
if (esFare) totalEs += esFare.price; else allPrices = false;
totalCircle += row.circle_fare || 0;
var kp = rowKey.split(':');
// outbound key: section:NRdep:ESdep — show NR dep (Bristol)
// inbound key: section:NRdep:ESdep — show ES dep (destination, CET)
var depTime = SECTION_DIRECTIONS[sid] === 'outbound'
? kp[1] + ':' + kp[2]
: kp[3] + ':' + kp[4];
parts.push((SECTION_DIRECTIONS[sid] === 'outbound' ? 'Out ' : 'Ret ') + depTime);
});
var descEl = document.getElementById('sel-desc');
if (descEl) descEl.textContent = parts.join(' · ');
var hintEl = document.getElementById('sel-hint');
if (hintEl) {
if (allSids.length > 1 && activeSids.length < allSids.length) {
var missingDir = SECTION_DIRECTIONS[allSids.filter(function(s) { return !selectedRowKeys[s]; })[0]];
hintEl.textContent = 'Select a ' + (missingDir === 'outbound' ? 'outbound' : 'return') + ' journey to see combined total';
hintEl.style.display = '';
} else {
hintEl.style.display = 'none';
}
}
var grandTotal = totalNr + totalEs + totalCircle;
var nrEl = document.getElementById('sel-nr');
var esEl = document.getElementById('sel-es');
var grandEl = document.getElementById('sel-grand');
if (nrEl) nrEl.innerHTML = 'NR&nbsp;<strong>' + (allPrices ? fmtPrice(totalNr) : '') + '</strong>';
if (esEl) esEl.innerHTML = 'Eurostar&nbsp;<strong>' + (allPrices ? fmtPrice(totalEs) : '') + '</strong>';
if (grandEl) {
var label = (activeSids.length === allSids.length && allSids.length > 1) ? 'Grand total' : 'Total';
var priceHtml = allPrices
? '<strong style="font-size:1.05rem;color:#276749">' + fmtPrice(grandTotal) + '</strong>'
: '<strong></strong>';
grandEl.innerHTML = label + '&nbsp;' + priceHtml;
}
}
function initialiseResultsPage() {
initSelectionFromUrl();
var needsAdvance = Object.keys(currentNrClasses).some(function(sid) {
var c = currentNrClasses[sid];
return c === 'advance_std' || c === 'advance_1st';
});
if (needsAdvance) loadMissingAdvanceFares();
updateDisplay();
updateRowHighlights();
loadWalkonFares();
startTimetableRefresh();
}
@ -548,15 +685,15 @@
<thead>
<tr>
{% if section.direction == 'inbound' %}
<th>Eurostar<br><span class="text-xs font-normal text-muted">{{ destination }} &rarr; St Pancras</span></th>
<th class="col-transfer">Transfer<br><span class="text-xs font-normal text-muted">St Pancras &rarr; Paddington</span></th>
<th class="flow-step">Eurostar<br><span class="text-xs font-normal text-muted">{{ destination }} &rarr; St Pancras</span></th>
<th class="col-transfer flow-step">Transfer<br><span class="text-xs font-normal text-muted">St Pancras &rarr; Paddington</span></th>
<th>National Rail<br><span class="text-xs font-normal text-muted">Paddington &rarr; {{ departure_station_name }}</span></th>
{% else %}
<th>National Rail<br><span class="text-xs font-normal text-muted">{{ departure_station_name }} &rarr; Paddington</span></th>
<th class="col-transfer">Transfer<br><span class="text-xs font-normal text-muted">Paddington &rarr; St Pancras</span></th>
<th class="flow-step">National Rail<br><span class="text-xs font-normal text-muted">{{ departure_station_name }} &rarr; Paddington</span></th>
<th class="col-transfer flow-step">Transfer<br><span class="text-xs font-normal text-muted">Paddington &rarr; St Pancras</span></th>
<th>Eurostar<br><span class="text-xs font-normal text-muted">St Pancras &rarr; {{ destination }}</span></th>
{% endif %}
<th class="nowrap">Total</th>
<th class="nowrap">Total<br><span class="text-xs font-normal text-muted">click row to select</span></th>
</tr>
</thead>
<tbody>
@ -577,8 +714,10 @@
{% else %}
{% set row_class = '' %}
{% endif %}
<tr class="{{ row_class }}" data-row-key="{{ row.row_key }}"
{% if row.eurostar_price is not none %}data-es-std="{{ row.eurostar_price }}"{% endif %}>
<tr class="{{ row_class }}{% if row.row_type == 'trip' %} row-selectable{% endif %}"
data-row-key="{{ row.row_key }}"
{% if row.eurostar_price is not none %}data-es-std="{{ row.eurostar_price }}"{% endif %}
{% if row.row_type == 'trip' %}onclick="selectRow(this)"{% endif %}>
{% if row.row_type == 'trip' %}
{% if section.direction == 'inbound' %}
<td>
@ -591,8 +730,9 @@
{%- 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>
<span class="fare-line es-standard"><span class="text-xs text-muted">Std</span>{% 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"><span class="text-xs text-muted">SP</span>{% if row.eurostar_plus_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
<span class="text-xs text-muted mobile-conn">then {{ row.connection_duration }} to station{% if row.connection_minutes < 45 %} {% 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>
@ -612,7 +752,7 @@
<span class="font-bold nowrap">{{ row.depart_paddington }} &rarr; {{ 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 %} &middot; {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} &middot; {% 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>
@ -623,11 +763,12 @@
<span class="font-bold nowrap">{{ row.depart_bristol }} &rarr; {{ 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 %} &middot; {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} &middot; {% 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>
<span class="text-xs text-muted mobile-conn">then {{ row.connection_duration }} to Eurostar{% if row.connection_minutes < 80 %} {% endif %}</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>
@ -649,8 +790,8 @@
{%- 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>
<span class="fare-line es-standard"><span class="text-xs text-muted">Std</span>{% 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"><span class="text-xs text-muted">SP</span>{% 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">
@ -665,7 +806,7 @@
</td>
{% else %}
<td>
<span class="text-dimmed text-sm">Too early</span>
<span class="text-dimmed text-sm" title="No National Rail service from {{ departure_station_name }} arrives at Paddington in time for this Eurostar">Too early</span>
</td>
<td class="col-transfer text-dimmed">&mdash;</td>
<td>
@ -676,8 +817,10 @@
<span class="font-bold nowrap text-dimmed">{{ row.depart_st_pancras }} &rarr; {{ row.arrive_destination }}</span>
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
{% endif %}
<span class="fare-line es-standard"></span>
<span class="fare-line es-plus"></span>
</td>
<td class="text-dimmed nowrap">No connection</td>
<td class="text-dimmed nowrap"><span class="total-price"></span></td>
{% endif %}
</tr>
{% endfor %}
@ -718,4 +861,19 @@
</p>
{% endif %}
<div id="selection-bar">
<div class="sel-bar-inner">
<div>
<span id="sel-desc" style="color:#2d3748"></span>
<span id="sel-hint" style="display:none; margin-left:1rem; color:#a0aec0; font-size:0.8rem"></span>
</div>
<div class="sel-totals">
<span id="sel-nr" class="text-muted"></span>
<span id="sel-es" class="text-muted"></span>
<span id="sel-grand"></span>
<button class="sel-clear" onclick="clearSelection()">Clear</button>
</div>
</div>
</div>
{% endblock %}

View file

@ -44,11 +44,24 @@ def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
]
def _circle_line_services_to_paddington(arrive_st_pancras: datetime) -> list[dict]:
PAD_WALK_FROM_UNDERGROUND_MINUTES = 5 # Circle line platform → GWR platform at Paddington
INBOUND_COMFORTABLE_MIN_CONN = 40 # threshold above which we apply the platform walk buffer
def _circle_line_services_to_paddington(
arrive_st_pancras: datetime,
dep_paddington: datetime | None = None,
min_conn_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
) -> list[dict]:
earliest_board = arrive_st_pancras + timedelta(
minutes=KX_WALK_TO_UNDERGROUND_MINUTES
)
services = circle_line.upcoming_services(earliest_board, count=1, direction='kx_to_pad', preceding=1)
if min_conn_minutes >= INBOUND_COMFORTABLE_MIN_CONN and dep_paddington is not None:
cutoff = dep_paddington - timedelta(minutes=PAD_WALK_FROM_UNDERGROUND_MINUTES)
candidates = circle_line.upcoming_services(earliest_board, count=4, direction='kx_to_pad')
services = [(dep, arr) for dep, arr in candidates if arr <= cutoff][:2]
else:
services = circle_line.upcoming_services(earliest_board, count=1, direction='kx_to_pad', preceding=1)
return [
{
"depart": dep.strftime(TIME_FMT),
@ -166,8 +179,8 @@ def combine_trips(
continue
dep_bri, arr_pad, dep_stp, arr_dest = connection
total_mins = int((arr_dest - dep_bri).total_seconds() / 60)
# Destination time is CET/CEST, departure is GMT/BST; Europe is always 1h ahead.
total_mins = int((arr_dest - dep_bri).total_seconds() / 60) - 60
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
fare = (gwr_fares or {}).get(gwr["depart_bristol"])
circle_svcs = _circle_line_services(arr_pad)
@ -226,11 +239,11 @@ def combine_inbound_trips(
if not connection:
continue
dep_dest, arr_stp, dep_pad, arr_station = connection
total_mins = int((arr_station - dep_dest).total_seconds() / 60)
# Destination time is CET/CEST, arrival at London is GMT/BST.
# Destination time is CET/CEST, arrival at London is GMT/BST; Europe is always 1h ahead.
total_mins = int((arr_station - dep_dest).total_seconds() / 60) + 60
eurostar_mins = int((arr_stp - dep_dest).total_seconds() / 60) + 60
fare = (gwr_fares or {}).get(gwr["depart_paddington"])
circle_svcs = _circle_line_services_to_paddington(arr_stp)
circle_svcs = _circle_line_services_to_paddington(arr_stp, dep_pad, min_connection_minutes)
trips.append(
{
"direction": "inbound",