Tidy results table layout and centralise connection defaults

- Redesign results table from 8 columns to 4 (National Rail, Transfer,
  Eurostar, Total), making GWR and Eurostar legs consistent with each other
- Move CET label next to Paris arrival time; show duration · train number
  on one line below
- Move "Too early" label into the National Rail column for unreachable rows
- Remove horizontal scrollbar (drop card-scroll / overflow-x: auto)
- Add DEFAULT_MIN_CONNECTION / DEFAULT_MAX_CONNECTION to config/default.py
  (70 / 150 min); remove all hardcoded fallback values from app.py and
  templates
- Redirect to clean URL when both connection params equal their defaults;
  omit params from all generated links when at default values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-10 13:11:26 +01:00
parent 6ab4534051
commit 8433252cae
5 changed files with 109 additions and 87 deletions

75
app.py
View file

@ -65,11 +65,14 @@ DESTINATIONS = {
@app.route("/") @app.route("/")
def index(): def index():
today = date.today().isoformat() today = date.today().isoformat()
default_min, default_max = _get_defaults()
return render_template( return render_template(
"index.html", "index.html",
destinations=DESTINATIONS, destinations=DESTINATIONS,
today=today, today=today,
stations=STATIONS, stations=STATIONS,
default_min_connection=default_min,
default_max_connection=default_max,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
valid_max_connections=sorted(VALID_MAX_CONNECTIONS), valid_max_connections=sorted(VALID_MAX_CONNECTIONS),
) )
@ -79,6 +82,21 @@ 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_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180}
def _get_defaults():
return (
app.config["DEFAULT_MIN_CONNECTION"],
app.config["DEFAULT_MAX_CONNECTION"],
)
def _parse_connection(raw, default, valid_set):
try:
val = int(raw)
except (TypeError, ValueError):
return default
return val if val in valid_set else default
@app.route("/search") @app.route("/search")
def search(): def search():
slug = request.args.get("destination", "") slug = request.args.get("destination", "")
@ -86,18 +104,13 @@ def search():
station_crs = request.args.get("station_crs", "BRI") station_crs = request.args.get("station_crs", "BRI")
if station_crs not in STATION_BY_CRS: if station_crs not in STATION_BY_CRS:
station_crs = "BRI" station_crs = "BRI"
try: default_min, default_max = _get_defaults()
min_conn = int(request.args.get("min_connection", 50)) min_conn = _parse_connection(
except ValueError: request.args.get("min_connection"), default_min, VALID_MIN_CONNECTIONS
min_conn = 50 )
if min_conn not in VALID_MIN_CONNECTIONS: max_conn = _parse_connection(
min_conn = 50 request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS
try: )
max_conn = int(request.args.get("max_connection", 110))
except ValueError:
max_conn = 110
if max_conn not in VALID_MAX_CONNECTIONS:
max_conn = 110
if slug in DESTINATIONS and travel_date: if slug in DESTINATIONS and travel_date:
return redirect( return redirect(
url_for( url_for(
@ -105,8 +118,8 @@ def search():
station_crs=station_crs, station_crs=station_crs,
slug=slug, slug=slug,
travel_date=travel_date, travel_date=travel_date,
min_connection=min_conn, min_connection=None if min_conn == default_min else min_conn,
max_connection=max_conn, max_connection=None if max_conn == default_max else max_conn,
) )
) )
return redirect(url_for("index")) return redirect(url_for("index"))
@ -121,18 +134,21 @@ def results(station_crs, slug, travel_date):
if not destination or not travel_date: if not destination or not travel_date:
return redirect(url_for("index")) return redirect(url_for("index"))
try: default_min, default_max = _get_defaults()
min_connection = int(request.args.get("min_connection", 50)) min_connection = _parse_connection(
except ValueError: request.args.get("min_connection"), default_min, VALID_MIN_CONNECTIONS
min_connection = 70 )
if min_connection not in VALID_MIN_CONNECTIONS: max_connection = _parse_connection(
min_connection = 70 request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS
try: )
max_connection = int(request.args.get("max_connection", 110))
except ValueError: # Redirect to clean URL when both params are at their defaults
max_connection = 150 if (
if max_connection not in VALID_MAX_CONNECTIONS: "min_connection" in request.args or "max_connection" in request.args
max_connection = 150 ) and min_connection == default_min and max_connection == default_max:
return redirect(
url_for("results", station_crs=station_crs, slug=slug, travel_date=travel_date)
)
user_agent = request.headers.get("User-Agent", rtt_scraper.DEFAULT_UA) user_agent = request.headers.get("User-Agent", rtt_scraper.DEFAULT_UA)
@ -254,6 +270,9 @@ def results(station_crs, slug, travel_date):
rtt_url = RTT_PADDINGTON_URL.format(crs=station_crs, date=travel_date) rtt_url = RTT_PADDINGTON_URL.format(crs=station_crs, date=travel_date)
rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date) rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date)
url_min = None if min_connection == default_min else min_connection
url_max = None if max_connection == default_max else max_connection
return render_template( return render_template(
"results.html", "results.html",
trips=trips, trips=trips,
@ -278,6 +297,10 @@ def results(station_crs, slug, travel_date):
rtt_station_url=rtt_station_url, rtt_station_url=rtt_station_url,
min_connection=min_connection, min_connection=min_connection,
max_connection=max_connection, max_connection=max_connection,
default_min_connection=default_min,
default_max_connection=default_max,
url_min_connection=url_min,
url_max_connection=url_max,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
valid_max_connections=sorted(VALID_MAX_CONNECTIONS), valid_max_connections=sorted(VALID_MAX_CONNECTIONS),
) )

View file

@ -8,3 +8,7 @@ CACHE_DIR = os.path.expanduser('~/lib/data/tfl/cache')
# TransXChange timetable file for the Circle Line # TransXChange timetable file for the Circle Line
CIRCLE_LINE_XML = os.path.join(TFL_DATA_DIR, 'output_txc_01CIR_.xml') CIRCLE_LINE_XML = os.path.join(TFL_DATA_DIR, 'output_txc_01CIR_.xml')
# Default connection window (minutes) between Paddington arrival and St Pancras departure
DEFAULT_MIN_CONNECTION = 70
DEFAULT_MAX_CONNECTION = 150

View file

@ -192,7 +192,6 @@
/* Card helpers */ /* Card helpers */
.card > h2:first-child { margin-top: 0; } .card > h2:first-child { margin-top: 0; }
.card-scroll { overflow-x: auto; }
/* Form groups */ /* Form groups */
.form-group { margin-bottom: 1.2rem; } .form-group { margin-bottom: 1.2rem; }

View file

@ -49,7 +49,7 @@
</label> </label>
<select id="min_connection" name="min_connection" class="form-control"> <select id="min_connection" name="min_connection" class="form-control">
{% for mins in valid_min_connections %} {% for mins in valid_min_connections %}
<option value="{{ mins }}" {% if mins == 70 %}selected{% endif %}>{{ mins }} min</option> <option value="{{ mins }}" {% if mins == default_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@ -60,7 +60,7 @@
</label> </label>
<select id="max_connection" name="max_connection" class="form-control"> <select id="max_connection" name="max_connection" class="form-control">
{% for mins in valid_max_connections %} {% for mins in valid_max_connections %}
<option value="{{ mins }}" {% if mins == 150 %}selected{% endif %}>{{ mins }} min</option> <option value="{{ mins }}" {% if mins == default_max_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>

View file

@ -15,10 +15,10 @@
{{ departure_station_name }} &rarr; {{ destination }} {{ departure_station_name }} &rarr; {{ destination }}
</h2> </h2>
<div class="date-nav"> <div class="date-nav">
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, min_connection=min_connection, max_connection=max_connection) }}" <a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, min_connection=url_min_connection, max_connection=url_max_connection) }}"
class="btn-nav">&larr; Prev</a> class="btn-nav">&larr; Prev</a>
<strong>{{ travel_date_display }}</strong> <strong>{{ travel_date_display }}</strong>
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, min_connection=min_connection, max_connection=max_connection) }}" <a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, min_connection=url_min_connection, max_connection=url_max_connection) }}"
class="btn-nav">Next &rarr;</a> class="btn-nav">Next &rarr;</a>
</div> </div>
<div class="switcher-section"> <div class="switcher-section">
@ -30,7 +30,7 @@
{% else %} {% else %}
<a <a
class="chip-link" class="chip-link"
href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, min_connection=min_connection, max_connection=max_connection) }}" href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, min_connection=url_min_connection, max_connection=url_max_connection) }}"
>{{ destination_name }}</a> >{{ destination_name }}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -64,9 +64,13 @@
</div> </div>
<script> <script>
function applyConnectionFilter() { function applyConnectionFilter() {
var min = document.getElementById('min_conn_select').value; var min = parseInt(document.getElementById('min_conn_select').value);
var max = document.getElementById('max_conn_select').value; var max = parseInt(document.getElementById('max_conn_select').value);
window.location = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}?min_connection=' + min + '&max_connection=' + max; var base = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}';
var params = [];
if (min !== {{ default_min_connection }}) params.push('min_connection=' + min);
if (max !== {{ default_max_connection }}) params.push('max_connection=' + max);
window.location = params.length ? base + '?' + params.join('&') : base;
} }
</script> </script>
<p class="card-meta"> <p class="card-meta">
@ -90,17 +94,13 @@
</div> </div>
{% if trips or unreachable_morning_services %} {% if trips or unreachable_morning_services %}
<div class="card card-scroll"> <div class="card">
<table class="results-table"> <table class="results-table">
<thead> <thead>
<tr> <tr>
<th class="nowrap">{{ departure_station_name }}</th> <th class="nowrap">National Rail</th>
<th class="nowrap">Paddington</th>
<th class="nowrap">GWR Fare</th>
<th class="col-transfer nowrap">Transfer</th> <th class="col-transfer nowrap">Transfer</th>
<th class="nowrap">Depart STP</th> <th class="nowrap">Eurostar</th>
<th>{{ destination }}</th>
<th class="nowrap">ES Std</th>
<th class="nowrap">Total</th> <th class="nowrap">Total</th>
</tr> </tr>
</thead> </thead>
@ -128,51 +128,50 @@
{% endif %} {% endif %}
<tr class="{{ row_class }}"> <tr class="{{ row_class }}">
{% if row.row_type == 'trip' %} {% if row.row_type == 'trip' %}
<td class="font-bold">
{{ row.depart_bristol }}
{% if row.headcode %}<br><span class="text-xs font-normal text-muted">{{ row.headcode }}</span>{% endif %}
</td>
<td> <td>
{{ row.arrive_paddington }} <span class="font-bold nowrap">{{ row.depart_bristol }} &rarr; {{ row.arrive_paddington }}</span>
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span> <span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
{% if row.arrive_platform %}<br><span class="text-xs text-muted">Plat {{ row.arrive_platform }}</span>{% endif %} {% if row.headcode or row.arrive_platform %}
</td> <br><span class="text-xs text-muted">
<td class="nowrap"> {%- if row.headcode %}{{ row.headcode }}{% endif %}
{%- if row.headcode and row.arrive_platform %} &middot; {% endif %}
{%- if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}
</span>
{% endif %}
{% if row.ticket_price is not none %} {% if row.ticket_price is not none %}
£{{ "%.2f"|format(row.ticket_price) }} <br><span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>
<br><span class="text-xs text-muted">{{ row.ticket_name }}</span> <span class="text-xs text-muted">{{ row.ticket_name }}</span>
{% else %} {% else %}
<span class="text-muted">&ndash;</span> <br><span class="text-sm text-muted">&ndash;</span>
{% endif %} {% endif %}
</td> </td>
<td class="col-transfer nowrap" style="color:#4a5568"> <td class="col-transfer" style="color:#4a5568">
{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %} <span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
{% if row.circle_services %} {% if row.circle_services %}
{% set c = row.circle_services[0] %} {% set c = row.circle_services[0] %}
<br><span class="text-xs text-muted">Circle {{ c.depart }} → KX {{ c.arrive_kx }}</span> <br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} → KX {{ c.arrive_kx }}</span>
{% if row.circle_services | length > 1 %} {% if row.circle_services | length > 1 %}
{% set c2 = row.circle_services[1] %} {% set c2 = row.circle_services[1] %}
<br><span class="text-xs text-muted" style="opacity:0.7">next {{ c2.depart }} → KX {{ c2.arrive_kx }}</span> <br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c2.depart }} → KX {{ c2.arrive_kx }}</span>
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>
<td class="font-bold">
{{ row.depart_st_pancras }}
{% if row.train_number %}<br><span class="text-xs font-normal text-muted">{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}</span>{% endif %}
</td>
<td> <td>
{{ row.arrive_destination }} <span class="font-bold nowrap">{{ row.depart_st_pancras }} &rarr; {{ row.arrive_destination }} <span class="font-normal text-muted" style="font-size:0.85em">(CET)</span></span>
<span class="font-normal text-muted" style="font-size:0.85em">(CET)</span> {% if row.eurostar_duration or row.train_number %}
{% if row.eurostar_duration %}<br><span class="text-sm text-muted nowrap">({{ row.eurostar_duration }})</span>{% endif %} <br><span class="text-xs text-muted">
</td> {%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
<td class="nowrap"> {%- if row.eurostar_duration and row.train_number %} &middot; {% endif %}
{%- if row.train_number %}{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
</span>
{% endif %}
{% if row.eurostar_price is not none %} {% if row.eurostar_price is not none %}
£{{ "%.2f"|format(row.eurostar_price) }} <br><span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>
{% if row.eurostar_seats is not none %} {% if row.eurostar_seats is not none %}
<br><span class="text-xs text-muted">{{ row.eurostar_seats }} at this price</span> <span class="text-xs text-muted">{{ row.eurostar_seats }} at this price</span>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-muted">&ndash;</span> <br><span class="text-sm text-muted">&ndash;</span>
{% endif %} {% endif %}
</td> </td>
<td class="font-bold nowrap"> <td class="font-bold nowrap">
@ -188,32 +187,29 @@
{% endif %} {% endif %}
</td> </td>
{% else %} {% else %}
<td class="font-bold">&mdash;</td>
<td>&mdash;</td>
<td>&mdash;</td>
<td class="col-transfer">&mdash;</td>
<td class="font-bold">
{{ row.depart_st_pancras }}
{% if row.train_number %}<br><span class="text-xs font-normal text-dimmed">{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}</span>{% endif %}
</td>
<td> <td>
{{ row.arrive_destination }} <span class="text-dimmed text-sm" title="Too early to reach from {{ departure_station_name }}">Too early</span>
<span class="font-normal text-dimmed" style="font-size:0.85em">(CET)</span>
{% if row.eurostar_duration %}<br><span class="text-sm text-dimmed nowrap">({{ row.eurostar_duration }})</span>{% endif %}
</td> </td>
<td class="nowrap"> <td class="col-transfer text-dimmed">&mdash;</td>
<td>
<span class="font-bold nowrap text-dimmed">{{ row.depart_st_pancras }} &rarr; {{ row.arrive_destination }} <span class="font-normal" style="font-size:0.85em">(CET)</span></span>
{% if row.eurostar_duration or row.train_number %}
<br><span class="text-xs text-dimmed">
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
{%- if row.eurostar_duration and row.train_number %} &middot; {% endif %}
{%- if row.train_number %}{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
</span>
{% endif %}
{% if row.eurostar_price is not none %} {% if row.eurostar_price is not none %}
<span class="text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span> <br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span>
{% if row.eurostar_seats is not none %} {% if row.eurostar_seats is not none %}
<br><span class="text-xs text-dimmed">{{ row.eurostar_seats }} at this price</span> <span class="text-xs text-dimmed">{{ row.eurostar_seats }} at this price</span>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="text-dimmed">&ndash;</span> <br><span class="text-sm text-dimmed">&ndash;</span>
{% endif %} {% endif %}
</td> </td>
<td class="font-bold"> <td class="text-dimmed">&mdash;</td>
<span title="Too early to reach from {{ departure_station_name }}" class="text-dimmed nowrap">Too early</span>
</td>
{% endif %} {% endif %}
</tr> </tr>
{% endfor %} {% endfor %}