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:
parent
cd37f0619b
commit
05eec29b7d
4 changed files with 38 additions and 17 deletions
21
app.py
21
app.py
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">–</span>
|
<span style="color:#718096">–</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">–</span>
|
<span style="color:#a0aec0">–</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>
|
||||||
|
|
|
||||||
|
|
@ -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:05–08:25 window)
|
# 07:00 on Friday 2026-04-10 → Anytime £138.70 (weekday, 05:05–08: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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue