Compare commits

..

No commits in common. "d089d3d7166a16c34fe15256740c969d53aaa930" and "6b044b9493dfe84f2f224b633b3056a7be6398e5" have entirely different histories.

7 changed files with 31 additions and 50 deletions

20
app.py
View file

@ -16,12 +16,6 @@ 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 = {
@ -36,16 +30,10 @@ DESTINATIONS = {
@app.route('/') @app.route('/')
def index(): def index():
today = date.today().isoformat() today = date.today().isoformat()
return render_template( return render_template('index.html', destinations=DESTINATIONS, today=today)
'index.html',
destinations=DESTINATIONS,
today=today,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
valid_max_connections=sorted(VALID_MAX_CONNECTIONS),
)
VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120} VALID_MIN_CONNECTIONS = {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}
@ -166,9 +154,8 @@ 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.search_url(destination, travel_date) eurostar_url = eurostar_scraper.timetable_url(destination) + f"?date={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',
@ -188,7 +175,6 @@ 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,14 +48,6 @@ 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 → St Pancras) Minimum connection time (Paddington → St 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 valid_min_connections %} {% for mins in [50, 60, 70, 80, 90, 100, 110, 120] %}
<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 valid_max_connections %} {% for mins in [60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180] %}
<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 }} Eurostar service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol {{ unreachable_morning_services | length }} morning service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol
</span> </span>
{% endif %} {% endif %}
{% if from_cache %} {% if from_cache %}
@ -116,11 +116,6 @@
{% 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 %}
@ -142,14 +137,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;white-space:nowrap">({{ row.gwr_duration }})</span> <span style="font-size:0.8rem;color:#718096">({{ 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 }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %} {{ row.connection_duration }}
</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 }}
@ -168,14 +163,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 journey">{{ row.total_duration }} ⚡</span> <span style="color:#276749" title="Fastest option">{{ 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 journey">{{ row.total_duration }} 🐢</span> <span style="color:#c53030" title="Slowest option">{{ 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) }}{% 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> <br><span style="font-size:0.8rem;font-weight:700;color:#276749">£{{ "%.2f"|format(row.total_price) }}</span>
{% endif %} {% endif %}
</td> </td>
{% else %} {% else %}
@ -210,12 +205,13 @@
<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 journey"') == 2 assert html.count('title="Fastest option"') == 2
assert html.count('title="Slowest journey"') == 2 assert html.count('title="Slowest option"') == 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,10 +211,11 @@ 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 '2 Eurostar services unavailable from Bristol' in html assert '1 morning service 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')
@ -266,6 +267,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 Eurostar service unavailable from Bristol' in html assert '1 morning 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,9 +131,7 @@ def test_connection_duration_in_trip():
assert trips[0]['connection_duration'] == '1h 16m' assert trips[0]['connection_duration'] == '1h 16m'
def test_find_unreachable_eurostars_excludes_connectable_services(): def test_unreachable_morning_eurostars_lists_only_unreachable_morning_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'},
] ]
@ -145,7 +143,7 @@ def test_find_unreachable_eurostars_excludes_connectable_services():
unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE) unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE)
assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30'] assert [service['depart_st_pancras'] for service in unreachable] == ['09:30']
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -207,7 +205,7 @@ def test_combine_trips_includes_ticket_fields():
assert 'ticket_code' in t assert 'ticket_code' in t
def test_find_unreachable_eurostars_returns_empty_when_all_connectable(): def test_unreachable_morning_eurostars_returns_empty_when_morning_service_is_reachable():
gwr = [ gwr = [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45'}, {'depart_bristol': '07:00', 'arrive_paddington': '08:45'},
] ]

View file

@ -6,6 +6,7 @@ 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'
@ -127,7 +128,6 @@ 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,6 +155,14 @@ 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,