Show cheapest GWR fare per journey and flag unreachable morning Eurostars

Add cheapest_gwr_ticket() to trip_planner.py encoding the SSS/SVS/SDS
walk-on single restrictions for Bristol Temple Meads → Paddington: on
weekdays, Super Off-Peak (£45) is valid before 05:05 or from 09:58,
Off-Peak (£63.60) from 08:26, and Anytime (£138.70) covers the gap.
Weekends have no restrictions. The fare is included in each trip dict
and displayed in a new GWR Fare column on the results page.

Also wire up find_unreachable_morning_eurostars() into the results view
so early Eurostar services unreachable from Bristol appear in the table,
with tests covering both features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-04 10:22:47 +01:00
parent b88d23a270
commit 804fcedfad
5 changed files with 428 additions and 44 deletions

View file

@ -75,6 +75,12 @@
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
&nbsp;&middot;&nbsp;
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
{% if unreachable_morning_services %}
&nbsp;&middot;&nbsp;
<span style="color:#718096">
{{ unreachable_morning_services | length }} morning service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol
</span>
{% endif %}
{% if from_cache %}
&nbsp;&middot;&nbsp; <span style="color:#718096;font-size:0.85rem">(cached)</span>
{% endif %}
@ -86,13 +92,14 @@
{% endif %}
</div>
{% if trips %}
{% if trips or unreachable_morning_services %}
<div class="card" style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.95rem">
<thead>
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Bristol</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Paddington</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">GWR Fare</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Transfer</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Depart St&nbsp;Pancras</th>
<th style="padding:0.6rem 0.8rem">{{ destination }}
@ -101,47 +108,73 @@
</tr>
</thead>
<tbody>
{% if trips %}
{% set best_mins = trips | map(attribute='total_minutes') | min %}
{% set worst_mins = trips | map(attribute='total_minutes') | max %}
{% for trip in trips %}
{% if trip.total_minutes == best_mins and trips | length > 1 %}
{% endif %}
{% for row in result_rows %}
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
{% set row_bg = 'background:#f0fff4' %}
{% elif trip.total_minutes == worst_mins and trips | length > 1 %}
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
{% set row_bg = 'background:#fff5f5' %}
{% elif row.row_type == 'unreachable' %}
{% set row_bg = 'background:#f7fafc;color:#a0aec0' %}
{% elif loop.index is odd %}
{% set row_bg = 'background:#f7fafc' %}
{% else %}
{% set row_bg = '' %}
{% endif %}
<tr style="border-bottom:1px solid #e2e8f0;{{ row_bg }}">
{% if row.row_type == 'trip' %}
<td style="padding:0.6rem 0.8rem;font-weight:600">
{{ trip.depart_bristol }}
{% if trip.headcode %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ trip.headcode }}</span>{% endif %}
{{ row.depart_bristol }}
{% if row.headcode %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ row.headcode }}</span>{% endif %}
</td>
<td style="padding:0.6rem 0.8rem">
{{ trip.arrive_paddington }}
<span style="font-size:0.8rem;color:#718096">({{ trip.gwr_duration }})</span>
{{ row.arrive_paddington }}
<span style="font-size:0.8rem;color:#718096">({{ row.gwr_duration }})</span>
</td>
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
£{{ "%.2f"|format(row.ticket_price) }}
<br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span>
</td>
<td style="padding:0.6rem 0.8rem;color:#4a5568">
{{ trip.connection_duration }}
{{ row.connection_duration }}
</td>
<td style="padding:0.6rem 0.8rem;font-weight:600">
{{ trip.depart_st_pancras }}
{% if trip.train_number %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ trip.train_number }}</span>{% endif %}
{{ row.depart_st_pancras }}
{% if row.train_number %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ row.train_number }}</span>{% endif %}
</td>
<td style="padding:0.6rem 0.8rem">
{{ trip.arrive_destination }}
{{ row.arrive_destination }}
<span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span>
</td>
<td style="padding:0.6rem 0.8rem;font-weight:600">
{% if trip.total_minutes == best_mins and trips | length > 1 %}
<span style="color:#276749" title="Fastest option">{{ trip.total_duration }} ⚡</span>
{% elif trip.total_minutes == worst_mins and trips | length > 1 %}
<span style="color:#c53030" title="Slowest option">{{ trip.total_duration }} 🐢</span>
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
<span style="color:#276749" title="Fastest option">{{ row.total_duration }} ⚡</span>
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
<span style="color:#c53030" title="Slowest option">{{ row.total_duration }} 🐢</span>
{% else %}
<span style="color:#00539f">{{ trip.total_duration }}</span>
<span style="color:#00539f">{{ row.total_duration }}</span>
{% endif %}
</td>
{% else %}
<td style="padding:0.6rem 0.8rem;font-weight:600">&mdash;</td>
<td style="padding:0.6rem 0.8rem">&mdash;</td>
<td style="padding:0.6rem 0.8rem">&mdash;</td>
<td style="padding:0.6rem 0.8rem">Unavailable</td>
<td style="padding:0.6rem 0.8rem;font-weight:600">
{{ row.depart_st_pancras }}
{% if row.train_number %}<br><span style="font-size:0.75rem;font-weight:400;color:#a0aec0">{{ row.train_number }}</span>{% endif %}
</td>
<td style="padding:0.6rem 0.8rem">
{{ row.arrive_destination }}
<span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span>
</td>
<td style="padding:0.6rem 0.8rem;font-weight:600">
<span title="No same-day Bristol connection">Unavailable from Bristol</span>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
@ -150,6 +183,9 @@
<p style="margin-top:1rem;font-size:0.82rem;color:#718096">
Paddington &rarr; St&nbsp;Pancras connection: {{ min_connection }}&ndash;{{ max_connection }}&nbsp;min.
{% if unreachable_morning_services %}
Morning means Eurostar departures before 12:00 from St&nbsp;Pancras.
{% endif %}
Eurostar times are from the general timetable and may vary; always check
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
&nbsp;&middot;&nbsp;