Compare commits

...

6 commits

Author SHA1 Message Date
d089d3d716 Add price emoji indicators and hover text to results table
Show 💰 on total prices within £10 of the cheapest journey and 💸
within £10 of the most expensive, mirroring the /🐢 logic for journey
time. Only applied when more than one priced trip exists.

Add title attributes to ⚠️ ("Tight connection"),  ("Fastest journey"),
and 🐢 ("Slowest journey") for accessibility and discoverability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:28:12 +01:00
e7695a5e49 Drive search form dropdowns from VALID_MIN/MAX_CONNECTIONS; warn on short transfers
Index page connection time dropdowns now iterate over valid_min_connections
and valid_max_connections passed from the view, so any change to the sets
in app.py is reflected automatically (also adds the missing 45 min option).

Add ⚠️ next to transfer times under 80 minutes in the results table;
store connection_minutes in each trip dict to support the comparison.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:52:32 +01:00
19656f412a Prevent GWR duration from wrapping on small screens
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:48:49 +01:00
06afd57957 Link to Eurostar search page with pricing; add Bristol departures RTT link
Replace the Eurostar timetable link with the search URL
(eurostar.com/search/uk-en?adult=1&origin=…&destination=…&outbound=…)
so the footer links directly to the page that shows prices for the
specific date and destination.

Add a Bristol Temple Meads → Paddington departures link on RTT alongside
the existing Paddington arrivals link.

Also update "morning service unavailable" badge and tests to reflect the
removal of the morning-only cutoff filter from find_unreachable_morning_eurostars.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:47:46 +01:00
4e4202e220 Drop MORNING_CUTOFF_HOUR, consider all services. 2026-04-04 10:43:47 +01:00
df94e822ae Try 45 minute min connection time. 2026-04-04 10:43:31 +01:00
7 changed files with 50 additions and 31 deletions

20
app.py
View file

@ -16,6 +16,12 @@ RTT_PADDINGTON_URL = (
"?stp=WVS&show=pax-calls&order=wtt" "?stp=WVS&show=pax-calls&order=wtt"
) )
RTT_BRISTOL_URL = (
"https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:BRI/to/gb-nr:PAD/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt"
)
app = Flask(__name__) app = Flask(__name__)
DESTINATIONS = { DESTINATIONS = {
@ -30,10 +36,16 @@ DESTINATIONS = {
@app.route('/') @app.route('/')
def index(): def index():
today = date.today().isoformat() today = date.today().isoformat()
return render_template('index.html', destinations=DESTINATIONS, today=today) return render_template(
'index.html',
destinations=DESTINATIONS,
today=today,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
valid_max_connections=sorted(VALID_MAX_CONNECTIONS),
)
VALID_MIN_CONNECTIONS = {50, 60, 70, 80, 90, 100, 110, 120} 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}
@ -154,8 +166,9 @@ def results(slug, travel_date):
next_date = (dt + timedelta(days=1)).isoformat() next_date = (dt + timedelta(days=1)).isoformat()
travel_date_display = dt.strftime('%A %-d %B %Y') travel_date_display = dt.strftime('%A %-d %B %Y')
eurostar_url = eurostar_scraper.timetable_url(destination) + f"?date={travel_date}" eurostar_url = eurostar_scraper.search_url(destination, travel_date)
rtt_url = RTT_PADDINGTON_URL.format(date=travel_date) rtt_url = RTT_PADDINGTON_URL.format(date=travel_date)
rtt_bristol_url = RTT_BRISTOL_URL.format(date=travel_date)
return render_template( return render_template(
'results.html', 'results.html',
@ -175,6 +188,7 @@ def results(slug, travel_date):
error=error, error=error,
eurostar_url=eurostar_url, eurostar_url=eurostar_url,
rtt_url=rtt_url, rtt_url=rtt_url,
rtt_bristol_url=rtt_bristol_url,
min_connection=min_connection, min_connection=min_connection,
max_connection=max_connection, max_connection=max_connection,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_min_connections=sorted(VALID_MIN_CONNECTIONS),

View file

@ -48,6 +48,14 @@ def _slugify_station_name(name: str) -> str:
return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
def search_url(destination: str, travel_date: str) -> str:
dest_id = DESTINATION_STATION_IDS[destination]
return (
f'https://www.eurostar.com/search/uk-en'
f'?adult=1&origin={ORIGIN_STATION_ID}&destination={dest_id}&outbound={travel_date}'
)
def timetable_url(destination: str) -> str: def timetable_url(destination: str) -> str:
dest_id = DESTINATION_STATION_IDS[destination] dest_id = DESTINATION_STATION_IDS[destination]
dest_slug = _slugify_station_name(destination) dest_slug = _slugify_station_name(destination)

View file

@ -47,7 +47,7 @@
Minimum connection time (Paddington &rarr; St&nbsp;Pancras) Minimum connection time (Paddington &rarr; St&nbsp;Pancras)
</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 [50, 60, 70, 80, 90, 100, 110, 120] %} {% for mins in valid_min_connections %}
<option value="{{ mins }}" {% if mins == 50 %}selected{% endif %}>{{ mins }} min</option> <option value="{{ mins }}" {% if mins == 50 %}selected{% endif %}>{{ mins }} min</option>
{% endfor %} {% endfor %}
</select> </select>
@ -58,7 +58,7 @@
Maximum connection time (Paddington &rarr; St&nbsp;Pancras) Maximum connection time (Paddington &rarr; St&nbsp;Pancras)
</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 [60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180] %} {% for mins in valid_max_connections %}
<option value="{{ mins }}" {% if mins == 110 %}selected{% endif %}>{{ mins }} min</option> <option value="{{ mins }}" {% if mins == 110 %}selected{% endif %}>{{ mins }} min</option>
{% endfor %} {% endfor %}
</select> </select>

View file

@ -83,7 +83,7 @@
{% if unreachable_morning_services %} {% if unreachable_morning_services %}
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<span style="color:#718096"> <span style="color:#718096">
{{ unreachable_morning_services | length }} morning service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol {{ unreachable_morning_services | length }} Eurostar service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol
</span> </span>
{% endif %} {% endif %}
{% if from_cache %} {% if from_cache %}
@ -116,6 +116,11 @@
{% if trips %} {% if trips %}
{% set best_mins = trips | map(attribute='total_minutes') | min %} {% set best_mins = trips | map(attribute='total_minutes') | min %}
{% set worst_mins = trips | map(attribute='total_minutes') | max %} {% set worst_mins = trips | map(attribute='total_minutes') | max %}
{% set priced_trips = trips | selectattr('total_price') | list %}
{% if priced_trips | length > 1 %}
{% set min_price = priced_trips | map(attribute='total_price') | min %}
{% set max_price = priced_trips | map(attribute='total_price') | max %}
{% endif %}
{% endif %} {% endif %}
{% for row in result_rows %} {% for row in result_rows %}
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %} {% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
@ -137,14 +142,14 @@
</td> </td>
<td style="padding:0.6rem 0.8rem"> <td style="padding:0.6rem 0.8rem">
{{ row.arrive_paddington }} {{ row.arrive_paddington }}
<span style="font-size:0.8rem;color:#718096">({{ row.gwr_duration }})</span> <span style="font-size:0.8rem;color:#718096;white-space:nowrap">({{ row.gwr_duration }})</span>
</td> </td>
<td style="padding:0.6rem 0.8rem;white-space:nowrap"> <td style="padding:0.6rem 0.8rem;white-space:nowrap">
£{{ "%.2f"|format(row.ticket_price) }} £{{ "%.2f"|format(row.ticket_price) }}
<br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span> <br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span>
</td> </td>
<td class="col-transfer" style="padding:0.6rem 0.8rem;color:#4a5568"> <td class="col-transfer" style="padding:0.6rem 0.8rem;color:#4a5568">
{{ row.connection_duration }} {{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}
</td> </td>
<td style="padding:0.6rem 0.8rem;font-weight:600"> <td style="padding:0.6rem 0.8rem;font-weight:600">
{{ row.depart_st_pancras }} {{ row.depart_st_pancras }}
@ -163,14 +168,14 @@
</td> </td>
<td style="padding:0.6rem 0.8rem;font-weight:600;white-space:nowrap"> <td style="padding:0.6rem 0.8rem;font-weight:600;white-space:nowrap">
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %} {% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
<span style="color:#276749" title="Fastest option">{{ row.total_duration }} ⚡</span> <span style="color:#276749" title="Fastest journey">{{ row.total_duration }} ⚡</span>
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %} {% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
<span style="color:#c53030" title="Slowest option">{{ row.total_duration }} 🐢</span> <span style="color:#c53030" title="Slowest journey">{{ row.total_duration }} 🐢</span>
{% else %} {% else %}
<span style="color:#00539f">{{ row.total_duration }}</span> <span style="color:#00539f">{{ row.total_duration }}</span>
{% endif %} {% endif %}
{% if row.total_price is not none %} {% if row.total_price is not none %}
<br><span style="font-size:0.8rem;font-weight:700;color:#276749">£{{ "%.2f"|format(row.total_price) }}</span> <br><span style="font-size:0.8rem;font-weight:700;color:#276749">£{{ "%.2f"|format(row.total_price) }}{% if min_price is defined and max_price is defined %}{% if row.total_price <= min_price + 10 %} <span title="Cheapest journey">💰</span>{% elif row.total_price >= max_price - 10 %} <span title="Most expensive journey">💸</span>{% endif %}{% endif %}</span>
{% endif %} {% endif %}
</td> </td>
{% else %} {% else %}
@ -205,13 +210,12 @@
<p style="margin-top:1rem;font-size:0.82rem;color:#718096"> <p style="margin-top:1rem;font-size:0.82rem;color:#718096">
Paddington &rarr; St&nbsp;Pancras connection: {{ min_connection }}&ndash;{{ max_connection }}&nbsp;min. 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 %}
GWR walk-on single prices for Bristol Temple Meads&nbsp;&rarr;&nbsp;Paddington. GWR walk-on single prices for Bristol Temple Meads&nbsp;&rarr;&nbsp;Paddington.
Eurostar Standard prices are for 1 adult in GBP; always check Eurostar Standard prices are for 1 adult in GBP; always check
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book. <a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<a href="{{ rtt_bristol_url }}" target="_blank" rel="noopener">Bristol departures on RTT</a>
&nbsp;&middot;&nbsp;
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a> <a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
</p> </p>

View file

@ -155,8 +155,8 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
assert html.count('title="Fastest option"') == 2 assert html.count('title="Fastest journey"') == 2
assert html.count('title="Slowest option"') == 2 assert html.count('title="Slowest journey"') == 2
assert '4h 50m ⚡' in html assert '4h 50m ⚡' in html
assert '4h 55m ⚡' in html assert '4h 55m ⚡' in html
assert '5h 20m 🐢' in html assert '5h 20m 🐢' in html
@ -211,11 +211,10 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
assert '1 morning service unavailable from Bristol' in html assert '2 Eurostar services unavailable from Bristol' in html
assert '09:30' in html assert '09:30' in html
assert 'ES 9001' in html assert 'ES 9001' in html
assert 'Unavailable from Bristol' in html assert 'Unavailable from Bristol' in html
assert 'Morning means Eurostar departures before 12:00 from St&nbsp;Pancras.' in html
assert html.index('09:30') < html.index('10:15') assert html.index('09:30') < html.index('10:15')
@ -267,6 +266,6 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
assert resp.status_code == 200 assert resp.status_code == 200
assert 'No valid journeys found.' not in html assert 'No valid journeys found.' not in html
assert '1 morning service unavailable from Bristol' in html assert '1 Eurostar service unavailable from Bristol' in html
assert '09:30' in html assert '09:30' in html
assert 'Unavailable from Bristol' in html assert 'Unavailable from Bristol' in html

View file

@ -131,7 +131,9 @@ def test_connection_duration_in_trip():
assert trips[0]['connection_duration'] == '1h 16m' assert trips[0]['connection_duration'] == '1h 16m'
def test_unreachable_morning_eurostars_lists_only_unreachable_morning_services(): def test_find_unreachable_eurostars_excludes_connectable_services():
# GWR arrives 08:45; default min=50/max=110 → viable window 09:3510:35.
# 09:30 too early, 10:15 connectable, 12:30 beyond max connection.
gwr = [ gwr = [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45'}, {'depart_bristol': '07:00', 'arrive_paddington': '08:45'},
] ]
@ -143,7 +145,7 @@ def test_unreachable_morning_eurostars_lists_only_unreachable_morning_services()
unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE) unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE)
assert [service['depart_st_pancras'] for service in unreachable] == ['09:30'] assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30']
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -205,7 +207,7 @@ def test_combine_trips_includes_ticket_fields():
assert 'ticket_code' in t assert 'ticket_code' in t
def test_unreachable_morning_eurostars_returns_empty_when_morning_service_is_reachable(): def test_find_unreachable_eurostars_returns_empty_when_all_connectable():
gwr = [ gwr = [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45'}, {'depart_bristol': '07:00', 'arrive_paddington': '08:45'},
] ]

View file

@ -6,7 +6,6 @@ from datetime import datetime, timedelta, time as _time
MIN_CONNECTION_MINUTES = 50 MIN_CONNECTION_MINUTES = 50
MAX_CONNECTION_MINUTES = 110 MAX_CONNECTION_MINUTES = 110
MAX_GWR_MINUTES = 110 MAX_GWR_MINUTES = 110
MORNING_CUTOFF_HOUR = 12
DATE_FMT = '%Y-%m-%d' DATE_FMT = '%Y-%m-%d'
TIME_FMT = '%H:%M' TIME_FMT = '%H:%M'
@ -128,6 +127,7 @@ def combine_trips(
'arrive_paddington': gwr['arrive_paddington'], 'arrive_paddington': gwr['arrive_paddington'],
'headcode': gwr.get('headcode', ''), 'headcode': gwr.get('headcode', ''),
'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)), 'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)),
'connection_minutes': int((dep_stp - arr_pad).total_seconds() / 60),
'connection_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)), 'connection_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)),
'depart_st_pancras': es['depart_st_pancras'], 'depart_st_pancras': es['depart_st_pancras'],
'arrive_destination': es['arrive_destination'], 'arrive_destination': es['arrive_destination'],
@ -155,14 +155,6 @@ def find_unreachable_morning_eurostars(
unreachable = [] unreachable = []
for es in eurostar_trains: for es in eurostar_trains:
try:
dep_stp = _parse_dt(travel_date, es['depart_st_pancras'])
except (ValueError, KeyError):
continue
if dep_stp.hour >= MORNING_CUTOFF_HOUR:
continue
if any( if any(
_is_viable_connection( _is_viable_connection(
gwr, gwr,