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)
# 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:
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
if es_price is not None:
trip['total_price'] = trip['ticket_price'] + es_price
else:
trip['total_price'] = None
trip['eurostar_seats'] = es.get('seats')
trip['total_price'] = trip['ticket_price'] + es_price if es_price is not None else 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(
gwr_trains,
@ -163,7 +167,9 @@ def results(slug, travel_date):
max_connection,
)
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(
[{'row_type': 'trip', **trip} for trip in trips]
@ -196,6 +202,7 @@ def results(slug, travel_date):
eurostar_count=len(eurostar_trains),
from_cache=from_cache,
error=error,
no_prices_note=no_prices_note,
eurostar_url=eurostar_url,
rtt_url=rtt_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))
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}
None means the class is sold out or unavailable for that departure.
Result: {depart_st_pancras: {'price': int_or_None, 'seats': int_or_None}}
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]
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.raise_for_status()
data = resp.json()
prices: dict[str, int | None] = {}
prices: dict[str, dict] = {}
journeys = data['data']['journeySearch']['outbound']['journeys']
for journey in journeys:
dep = journey['timing']['departureTime']
price = None
seats = None
for fare in journey['fares']:
if fare['classOfService']['code'] == 'STANDARD':
p = fare.get('prices')
if p and p.get('displayPrice'):
price = int(p['displayPrice'])
seats = fare.get('seats')
break
prices[dep] = price
prices[dep] = {'price': price, 'seats': seats}
return prices

View file

@ -95,6 +95,11 @@
<strong>Warning:</strong> {{ error }}
</div>
{% 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>
{% if trips or unreachable_morning_services %}
@ -164,6 +169,9 @@
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
{% if row.eurostar_price is not none %}
£{{ 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 %}
<span style="color:#718096">&ndash;</span>
{% endif %}
@ -197,12 +205,15 @@
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
{% if row.eurostar_price is not none %}
<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 %}
<span style="color:#a0aec0">&ndash;</span>
{% endif %}
</td>
<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>
{% endif %}
</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 '09:30' 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')
def test_results_shows_eurostar_price_and_total(monkeypatch):
# 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()
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 '1 Eurostar service unavailable from Bristol' in html
assert '09:30' in html
assert 'Unavailable from Bristol' in html
assert 'Too early' in html