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:
parent
a88b19fa4c
commit
1bc7631863
5 changed files with 695 additions and 122 deletions
13
app.py
13
app.py
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">‹</button>
|
||||
<div id="cal-titles"></div>
|
||||
<button type="button" id="cal-next" class="cal-nav-btn" aria-label="Next month">›</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> · <span class="cal-cta">Now select return date</span>';
|
||||
} else {
|
||||
el.innerHTML = 'Depart: <strong>' + dispDate(outDate) +
|
||||
'</strong> · 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 %}
|
||||
|
|
|
|||
|
|
@ -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 <strong>' + (allPrices ? fmtPrice(totalNr) : '–') + '</strong>';
|
||||
if (esEl) esEl.innerHTML = 'Eurostar <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 + ' ' + 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 }} → St Pancras</span></th>
|
||||
<th class="col-transfer">Transfer<br><span class="text-xs font-normal text-muted">St Pancras → Paddington</span></th>
|
||||
<th class="flow-step">Eurostar<br><span class="text-xs font-normal text-muted">{{ destination }} → St Pancras</span></th>
|
||||
<th class="col-transfer flow-step">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 class="flow-step">National Rail<br><span class="text-xs font-normal text-muted">{{ departure_station_name }} → Paddington</span></th>
|
||||
<th class="col-transfer flow-step">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>
|
||||
<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 }} → {{ 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>
|
||||
<br><span class="text-xs text-muted mobile-hide">{{ 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>
|
||||
|
|
@ -623,11 +763,12 @@
|
|||
<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>
|
||||
<br><span class="text-xs text-muted mobile-hide">{{ 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>
|
||||
<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">—</td>
|
||||
<td>
|
||||
|
|
@ -676,8 +817,10 @@
|
|||
<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 %}
|
||||
<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 %}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue