Show Eurostar seat availability and no-prices notice

fetch_prices now returns {'price': ..., 'seats': ...} per departure.
Seat count (labelled "N at this price") is shown below the fare — it
reflects price-band depth rather than total remaining seats. A yellow
notice is shown when the API returns journeys but all prices are null
(tickets not yet on sale).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-04 14:12:54 +01:00
parent cd37f0619b
commit 05eec29b7d
4 changed files with 38 additions and 17 deletions

21
app.py
View file

@ -146,14 +146,18 @@ def results(slug, travel_date):
trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection) trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection)
# Annotate each trip with Eurostar Standard price and total cost # Annotate each trip with Eurostar Standard price, seats, and total cost
for trip in trips: for trip in trips:
es_price = eurostar_prices.get(trip['depart_st_pancras']) es = eurostar_prices.get(trip['depart_st_pancras'], {})
es_price = es.get('price')
trip['eurostar_price'] = es_price trip['eurostar_price'] = es_price
if es_price is not None: trip['eurostar_seats'] = es.get('seats')
trip['total_price'] = trip['ticket_price'] + es_price trip['total_price'] = trip['ticket_price'] + es_price if es_price is not None else None
else:
trip['total_price'] = None # If the API returned journeys but every price is None, tickets aren't on sale yet
no_prices_note = None
if eurostar_prices and all(v.get('price') is None for v in eurostar_prices.values()):
no_prices_note = 'Eurostar prices not yet available — tickets may not be on sale yet.'
unreachable_morning_services = find_unreachable_morning_eurostars( unreachable_morning_services = find_unreachable_morning_eurostars(
gwr_trains, gwr_trains,
@ -163,7 +167,9 @@ def results(slug, travel_date):
max_connection, max_connection,
) )
for svc in unreachable_morning_services: for svc in unreachable_morning_services:
svc['eurostar_price'] = eurostar_prices.get(svc['depart_st_pancras']) es = eurostar_prices.get(svc['depart_st_pancras'], {})
svc['eurostar_price'] = es.get('price')
svc['eurostar_seats'] = es.get('seats')
result_rows = sorted( result_rows = sorted(
[{'row_type': 'trip', **trip} for trip in trips] [{'row_type': 'trip', **trip} for trip in trips]
@ -196,6 +202,7 @@ def results(slug, travel_date):
eurostar_count=len(eurostar_trains), eurostar_count=len(eurostar_trains),
from_cache=from_cache, from_cache=from_cache,
error=error, error=error,
no_prices_note=no_prices_note,
eurostar_url=eurostar_url, eurostar_url=eurostar_url,
rtt_url=rtt_url, rtt_url=rtt_url,
rtt_bristol_url=rtt_bristol_url, rtt_bristol_url=rtt_bristol_url,

View file

@ -162,12 +162,13 @@ def _generate_cid() -> str:
return 'SRCH-' + ''.join(random.choices(chars, k=22)) return 'SRCH-' + ''.join(random.choices(chars, k=22))
def fetch_prices(destination: str, travel_date: str) -> dict[str, int | None]: def fetch_prices(destination: str, travel_date: str) -> dict[str, dict]:
""" """
Return Eurostar Standard prices for every departure on travel_date. Return Eurostar Standard price and seat availability for every departure on travel_date.
Result: {depart_st_pancras: price_gbp_int_or_None} Result: {depart_st_pancras: {'price': int_or_None, 'seats': int_or_None}}
None means the class is sold out or unavailable for that departure. price is None when unavailable/not yet on sale; seats is the number of
Standard seats currently available for sale.
""" """
dest_id = DESTINATION_STATION_IDS[destination] dest_id = DESTINATION_STATION_IDS[destination]
headers = { headers = {
@ -196,16 +197,18 @@ def fetch_prices(destination: str, travel_date: str) -> dict[str, int | None]:
resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20) resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
prices: dict[str, int | None] = {} prices: dict[str, dict] = {}
journeys = data['data']['journeySearch']['outbound']['journeys'] journeys = data['data']['journeySearch']['outbound']['journeys']
for journey in journeys: for journey in journeys:
dep = journey['timing']['departureTime'] dep = journey['timing']['departureTime']
price = None price = None
seats = None
for fare in journey['fares']: for fare in journey['fares']:
if fare['classOfService']['code'] == 'STANDARD': if fare['classOfService']['code'] == 'STANDARD':
p = fare.get('prices') p = fare.get('prices')
if p and p.get('displayPrice'): if p and p.get('displayPrice'):
price = int(p['displayPrice']) price = int(p['displayPrice'])
seats = fare.get('seats')
break break
prices[dep] = price prices[dep] = {'price': price, 'seats': seats}
return prices return prices

View file

@ -95,6 +95,11 @@
<strong>Warning:</strong> {{ error }} <strong>Warning:</strong> {{ error }}
</div> </div>
{% endif %} {% endif %}
{% if no_prices_note %}
<div style="margin-top:1rem;padding:0.75rem 1rem;background:#fffbeb;border:1px solid #f6e05e;border-radius:4px;color:#744210">
{{ no_prices_note }}
</div>
{% endif %}
</div> </div>
{% if trips or unreachable_morning_services %} {% if trips or unreachable_morning_services %}
@ -164,6 +169,9 @@
<td style="padding:0.6rem 0.8rem;white-space:nowrap"> <td style="padding:0.6rem 0.8rem;white-space:nowrap">
{% if row.eurostar_price is not none %} {% if row.eurostar_price is not none %}
£{{ row.eurostar_price }} £{{ row.eurostar_price }}
{% if row.eurostar_seats is not none %}
<br><span style="font-size:0.75rem;color:#718096">{{ row.eurostar_seats }} at this price</span>
{% endif %}
{% else %} {% else %}
<span style="color:#718096">&ndash;</span> <span style="color:#718096">&ndash;</span>
{% endif %} {% endif %}
@ -197,12 +205,15 @@
<td style="padding:0.6rem 0.8rem;white-space:nowrap"> <td style="padding:0.6rem 0.8rem;white-space:nowrap">
{% if row.eurostar_price is not none %} {% if row.eurostar_price is not none %}
<span style="color:#a0aec0">£{{ row.eurostar_price }}</span> <span style="color:#a0aec0">£{{ row.eurostar_price }}</span>
{% if row.eurostar_seats is not none %}
<br><span style="font-size:0.75rem;color:#a0aec0">{{ row.eurostar_seats }} at this price</span>
{% endif %}
{% else %} {% else %}
<span style="color:#a0aec0">&ndash;</span> <span style="color:#a0aec0">&ndash;</span>
{% endif %} {% endif %}
</td> </td>
<td style="padding:0.6rem 0.8rem;font-weight:600"> <td style="padding:0.6rem 0.8rem;font-weight:600">
<span title="No same-day Bristol connection" style="color:#a0aec0">Too early</span> <span title="No same-day Bristol connection" style="color:#a0aec0;white-space:nowrap">Too early</span>
</td> </td>
{% endif %} {% endif %}
</tr> </tr>

View file

@ -214,13 +214,13 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
assert '2 Eurostar services 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 'Too early' in html
assert html.index('09:30') < html.index('10:15') assert html.index('09:30') < html.index('10:15')
def test_results_shows_eurostar_price_and_total(monkeypatch): def test_results_shows_eurostar_price_and_total(monkeypatch):
# 07:00 on Friday 2026-04-10 → Anytime £138.70 (weekday, 05:0508:25 window) # 07:00 on Friday 2026-04-10 → Anytime £138.70 (weekday, 05:0508:25 window)
_stub_data(monkeypatch, prices={'10:01': 59}) _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}})
client = _client() client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
@ -268,4 +268,4 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
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 Eurostar service unavailable from Bristol' in html
assert '09:30' in html assert '09:30' in html
assert 'Unavailable from Bristol' in html assert 'Too early' in html