Add multi-station support, GWR fares API, and Circle line improvements

- Support any station with direct trains to Paddington; station CRS code
  is now part of the URL (/results/<crs>/<slug>/<date>)
- Load station list from data/direct_to_paddington.tsv; show dropdown on
  index page; 404 for unknown station codes
- Fetch live GWR walk-on fares via api.gwr.com for all stations (SSS/SVS/SDS
  with restrictions already applied per train); cache 30 days
- Scrape Paddington arrival platform numbers from RTT
- Show unreachable morning Eurostars (before first reachable service only)
- Circle line: show actual KX St Pancras arrival times (not check-in estimate)
  and add a second backup service in the transfer column
- Widen page max-width to 1100px for longer station names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-06 20:22:44 +01:00
parent 71be0dd8cf
commit 3c787b33d3
12 changed files with 810 additions and 262 deletions

View file

@ -1,5 +1,5 @@
import pytest
from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration, cheapest_gwr_ticket
from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration
DATE = '2026-03-30'
@ -23,7 +23,7 @@ def test_fmt_duration_minutes_only():
# ---------------------------------------------------------------------------
GWR_FAST = {'depart_bristol': '07:00', 'arrive_paddington': '08:45'} # 1h 45m
GWR_SLOW = {'depart_bristol': '07:00', 'arrive_paddington': '09:26'} # 2h 26m — over limit
GWR_SLOW = {'depart_bristol': '07:00', 'arrive_paddington': '09:26'} # 2h 26m — connection too short for ES_PARIS
ES_PARIS = {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', 'destination': 'Paris Gare du Nord'}
ES_EARLY = {'depart_st_pancras': '09:00', 'arrive_destination': '12:00', 'destination': 'Paris Gare du Nord'}
@ -41,7 +41,7 @@ def test_valid_trip_is_returned():
def test_gwr_too_slow_excluded():
# 2h 26m GWR journey exceeds MAX_GWR_MINUTES (110)
# arrive 09:26, Eurostar 10:01 → 35 min connection < 50 min minimum
trips = combine_trips([GWR_SLOW], [ES_PARIS], DATE)
assert trips == []
@ -148,56 +148,6 @@ def test_find_unreachable_eurostars_excludes_connectable_services():
assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30']
# ---------------------------------------------------------------------------
# cheapest_gwr_ticket — Bristol Temple Meads → Paddington
# ---------------------------------------------------------------------------
# 2026-03-30 is a Monday; 2026-03-28 is a Saturday
def test_cheapest_ticket_weekday_super_off_peak_morning():
# 05:00 on Monday: dep ≤ 05:04 → Super Off-Peak
t = cheapest_gwr_ticket('05:00', '2026-03-30')
assert t['ticket'] == 'Super Off-Peak'
assert t['price'] == 45.00
def test_cheapest_ticket_weekday_anytime_window():
# 07:00 on Monday: 05:0508:25 → Anytime only
t = cheapest_gwr_ticket('07:00', '2026-03-30')
assert t['ticket'] == 'Anytime'
assert t['price'] == 138.70
def test_cheapest_ticket_weekday_off_peak():
# 08:30 on Monday: dep ≥ 08:26 but < 09:58 → Off-Peak
t = cheapest_gwr_ticket('08:30', '2026-03-30')
assert t['ticket'] == 'Off-Peak'
assert t['price'] == 63.60
def test_cheapest_ticket_weekday_super_off_peak_late():
# 10:00 on Monday: dep ≥ 09:58 → Super Off-Peak
t = cheapest_gwr_ticket('10:00', '2026-03-30')
assert t['ticket'] == 'Super Off-Peak'
assert t['price'] == 45.00
def test_cheapest_ticket_boundary_super_off_peak_cutoff():
# 05:04 is last valid minute for early Super Off-Peak
assert cheapest_gwr_ticket('05:04', '2026-03-30')['ticket'] == 'Super Off-Peak'
# 05:05 falls into the Anytime window (off-peak starts at 08:26)
assert cheapest_gwr_ticket('05:05', '2026-03-30')['ticket'] == 'Anytime'
def test_cheapest_ticket_boundary_off_peak_start():
assert cheapest_gwr_ticket('08:25', '2026-03-30')['ticket'] == 'Anytime'
assert cheapest_gwr_ticket('08:26', '2026-03-30')['ticket'] == 'Off-Peak'
def test_cheapest_ticket_boundary_super_off_peak_resumes():
assert cheapest_gwr_ticket('09:57', '2026-03-30')['ticket'] == 'Off-Peak'
assert cheapest_gwr_ticket('09:58', '2026-03-30')['ticket'] == 'Super Off-Peak'
def test_cheapest_ticket_weekend_always_super_off_peak():
# Saturday — no restrictions
t = cheapest_gwr_ticket('07:00', '2026-03-28')
assert t['ticket'] == 'Super Off-Peak'
assert t['price'] == 45.00
def test_combine_trips_includes_ticket_fields():
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert len(trips) == 1
@ -206,6 +156,18 @@ def test_combine_trips_includes_ticket_fields():
assert 'ticket_price' in t
assert 'ticket_code' in t
def test_combine_trips_uses_gwr_fares_when_provided():
fares = {'07:00': {'ticket': 'Super Off-Peak Single', 'price': 49.30, 'code': 'SSS'}}
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares=fares)
assert len(trips) == 1
assert trips[0]['ticket_price'] == 49.30
assert trips[0]['ticket_code'] == 'SSS'
def test_combine_trips_ticket_price_none_when_no_fares():
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares={})
assert len(trips) == 1
assert trips[0]['ticket_price'] is None
def test_find_unreachable_eurostars_returns_empty_when_all_connectable():
gwr = [