diff --git a/app.py b/app.py index 7753751..8a81ded 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,7 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains. """ -from flask import Flask, render_template, redirect, url_for, request, abort +from flask import Flask, render_template, redirect, url_for, request, abort, jsonify from datetime import date, timedelta from pathlib import Path import os @@ -155,10 +155,12 @@ def results(station_crs, slug, travel_date): rtt_cache_key = f"rtt_{station_crs}_{travel_date}" es_cache_key = f"eurostar_{travel_date}_{destination}" gwr_fares_cache_key = f"gwr_fares_{station_crs}_{travel_date}" + gwr_advance_cache_key = f"gwr_advance_{station_crs}_{travel_date}" cached_rtt = get_cached(rtt_cache_key) cached_es = get_cached(es_cache_key, ttl=24 * 3600) cached_gwr_fares = get_cached(gwr_fares_cache_key, ttl=30 * 24 * 3600) + cached_advance_fares = get_cached(gwr_advance_cache_key, ttl=24 * 3600) from_cache = bool(cached_rtt and cached_es) error = None @@ -197,7 +199,12 @@ def results(station_crs, slug, travel_date): eurostar_trains = eurostar_services eurostar_prices = { - s["depart_st_pancras"]: {"price": s.get("price"), "seats": s.get("seats")} + s["depart_st_pancras"]: { + "price": s.get("price"), + "seats": s.get("seats"), + "plus_price": s.get("plus_price"), + "plus_seats": s.get("plus_seats"), + } for s in eurostar_services } @@ -210,12 +217,14 @@ def results(station_crs, slug, travel_date): gwr_fares, ) - # Annotate each trip with Eurostar Standard price, seats, and total cost + # Annotate each trip with Eurostar prices and total cost (walk-on + standard) for trip in trips: es = eurostar_prices.get(trip["depart_st_pancras"], {}) es_price = es.get("price") trip["eurostar_price"] = es_price trip["eurostar_seats"] = es.get("seats") + trip["eurostar_plus_price"] = es.get("plus_price") + trip["eurostar_plus_seats"] = es.get("plus_seats") gwr_p = trip.get("ticket_price") trip["total_price"] = ( gwr_p + es_price if (gwr_p is not None and es_price is not None) else None @@ -241,6 +250,8 @@ def results(station_crs, slug, travel_date): es = eurostar_prices.get(svc["depart_st_pancras"], {}) svc["eurostar_price"] = es.get("price") svc["eurostar_seats"] = es.get("seats") + svc["eurostar_plus_price"] = es.get("plus_price") + svc["eurostar_plus_seats"] = es.get("plus_seats") # Only keep unreachable services that depart before the first reachable Eurostar. # Services after the first reachable one are omitted (they aren't "Too early"). @@ -301,10 +312,27 @@ def results(station_crs, slug, travel_date): default_max_connection=default_max, url_min_connection=url_min, url_max_connection=url_max, + cached_advance_fares=cached_advance_fares, valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_max_connections=sorted(VALID_MAX_CONNECTIONS), ) +@app.route("/api/advance_fares//") +def api_advance_fares(station_crs, travel_date): + if station_crs not in STATION_BY_CRS: + abort(404) + cache_key = f"gwr_advance_{station_crs}_{travel_date}" + cached = get_cached(cache_key, ttl=24 * 3600) + if cached is not None: + return jsonify(cached) + try: + fares = gwr_fares_scraper.fetch_advance(station_crs, travel_date) + set_cached(cache_key, fares) + return jsonify(fares) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + if __name__ == "__main__": app.run(debug=True, host="0.0.0.0") diff --git a/more_prices.md b/more_prices.md new file mode 100644 index 0000000..a195523 --- /dev/null +++ b/more_prices.md @@ -0,0 +1,17 @@ +Right now the site just shows National Rail (NR) walk on fares in Standard Class and Eurostar Standard prices. + +I'd like to see more prices, specifically: + +Eurostar Plus +National Rail Standard advance tickets +National Rail First class advance tickets + +Optionally we could include First class walk on fares, although they'll be expensive, I wouldn't never buy one. + +Our existing Eurostar scraper is already getting the Eurostar Plus prices, we could have a way to make them appear in the UI. + +Getting NR advanced fares is more tricky, we need to scrape the GWR booking system, it'll take multiple requests. We shouldn't get this price data by default. We could have a button to get the extra GWR prices then for each NR service show standard walk on, standard advance and 1st class advance prices. + +Not sure what to do about total price column, lots of combinations now. Maybe selectors so the user can pick which type of ticket they want for NR plus the type for Eurostar. + +I hope that makes some sense, can you have a go implementing. Ask me if I've left anything out. diff --git a/scraper/eurostar.py b/scraper/eurostar.py index 8b9a881..667decf 100644 --- a/scraper/eurostar.py +++ b/scraper/eurostar.py @@ -68,6 +68,9 @@ _GQL_QUERY = ( "}" ) +_STANDARD = 'STANDARD' +_STANDARD_PLUS = 'PLUS' + def search_url(destination: str, travel_date: str) -> str: dest_id = DESTINATION_STATION_IDS[destination] @@ -87,7 +90,7 @@ def _parse_graphql(data: dict, destination: str) -> list[dict]: Parse a NewBookingSearch GraphQL response into a list of service dicts. Each dict contains: depart_st_pancras, arrive_destination, destination, - train_number, price (float or None), seats (int or None). + train_number, price/seats (Standard), plus_price/plus_seats (Standard Premier). The same St Pancras departure can appear multiple times (different connecting trains); we keep the entry with the earliest arrival. @@ -98,26 +101,34 @@ def _parse_graphql(data: dict, destination: str) -> list[dict]: for journey in journeys: dep = journey['timing']['departureTime'] arr = journey['timing']['arrivalTime'] - for fare in journey['fares']: - if fare['classOfService']['code'] == 'STANDARD': - p = fare.get('prices') - price = float(p['displayPrice']) if p and p.get('displayPrice') else None - seats = fare.get('seats') + std_price = std_seats = plus_price = plus_seats = None + train_number = '' + for fare in (journey.get('fares') or []): + cos = fare['classOfService']['code'] + p = fare.get('prices') + price = float(p['displayPrice']) if p and p.get('displayPrice') else None + seats = fare.get('seats') + if not train_number: legs = fare.get('legs') or [] train_number = ' + '.join( f"{(leg.get('serviceType') or {}).get('code', 'ES')} {leg['serviceName']}" for leg in legs if leg.get('serviceName') ) - if dep not in best or arr < best[dep]['arrive_destination']: - best[dep] = { - 'depart_st_pancras': dep, - 'arrive_destination': arr, - 'destination': destination, - 'train_number': train_number, - 'price': price, - 'seats': seats, - } - break + if cos == _STANDARD: + std_price, std_seats = price, seats + elif cos == _STANDARD_PLUS: + plus_price, plus_seats = price, seats + if dep not in best or arr < best[dep]['arrive_destination']: + best[dep] = { + 'depart_st_pancras': dep, + 'arrive_destination': arr, + 'destination': destination, + 'train_number': train_number, + 'price': std_price, + 'seats': std_seats, + 'plus_price': plus_price, + 'plus_seats': plus_seats, + } return sorted(best.values(), key=lambda s: s['depart_st_pancras']) @@ -148,7 +159,7 @@ def fetch(destination: str, travel_date: str) -> list[dict]: 'outbound': travel_date, 'currency': 'GBP', 'adult': 1, - 'filteredClassesOfService': ['STANDARD'], + 'filteredClassesOfService': [_STANDARD, _STANDARD_PLUS], }, 'query': _GQL_QUERY, } diff --git a/scraper/gwr_fares.py b/scraper/gwr_fares.py index 0f37ab0..c36b0e9 100644 --- a/scraper/gwr_fares.py +++ b/scraper/gwr_fares.py @@ -12,7 +12,7 @@ _API_URL = "https://api.gwr.com/api/shopping/journeysearch" # API key is embedded in the GWR web app (appvalues.prod.json) _API_KEY = "OgovGqAlLp4gWAhL7DQLo7pMCt8GHi2U4SPFiZgG" _PAD_CODE = "GBQQP" # London Paddington cluster code as used by GWR website -_WANTED_CODES = {"SSS", "SVS", "SDS", "CDS"} +_WALKON_CODES = {"SSS", "SVS", "SDS", "CDS"} _MAX_PAGES = 20 @@ -67,59 +67,128 @@ def _request_body( } -def fetch(station_crs: str, travel_date: str) -> dict[str, dict]: +def _run_pages(station_crs: str, travel_date: str, first_class: bool = False): """ - Fetch GWR single fares from station_crs to London Paddington on travel_date. + Iterate all pages of GWR journey search results. - Returns {departure_time: {'ticket': name, 'price': float, 'code': code}} - where price is in £ and only the cheapest available standard-class ticket - per departure (with restrictions already applied by GWR) is kept. + Yields (dep_time, fares_list) for each unique departure time seen. + first_class=True switches the request to first class fares. """ - result: dict[str, dict] = {} - + seen: set[str] = set() with httpx.Client(headers=_headers(), timeout=30) as client: conversation_token = None later = False - for _ in range(_MAX_PAGES): body = _request_body(station_crs, travel_date, conversation_token, later) + if first_class: + body["firstclass"] = True + body["standardclass"] = False resp = client.post(_API_URL, json=body) resp.raise_for_status() data = resp.json().get("data", {}) - conversation_token = data.get("conversationToken") - for journey in data.get("outwardOpenPureReturnFare", []): dep_iso = journey.get("departureTime", "") dep_time = dep_iso[11:16] # "HH:MM" from "2026-04-10T09:08:00" - if not dep_time or dep_time in result: + if not dep_time or dep_time in seen: continue - - cheapest = None - for fare in journey.get("journeyFareDetails", []): - code = fare.get("ticketTypeCode") - if code not in _WANTED_CODES: - continue - if not fare.get("isStandardClass"): - continue - price_pence = fare.get("fare", 0) - if cheapest is None or price_pence < cheapest["price_pence"]: - cheapest = { - "ticket": fare.get("ticketType", ""), - "price": price_pence / 100, - "price_pence": price_pence, - "code": code, - } - - if cheapest: - result[dep_time] = { - "ticket": cheapest["ticket"], - "price": cheapest["price"], - "code": cheapest["code"], - } - + seen.add(dep_time) + yield dep_time, journey.get("journeyFareDetails", []) if not data.get("showLaterOutward", False): break later = True + +def fetch(station_crs: str, travel_date: str) -> dict[str, dict]: + """ + Fetch GWR walk-on single fares from station_crs to London Paddington on travel_date. + + Returns {departure_time: {'ticket': name, 'price': float, 'code': code}} + where price is in £ and only the cheapest available standard-class walk-on + ticket per departure (with restrictions already applied by GWR) is kept. + """ + result: dict[str, dict] = {} + for dep_time, fares in _run_pages(station_crs, travel_date): + cheapest = None + for fare in fares: + code = fare.get("ticketTypeCode") + if code not in _WALKON_CODES: + continue + if not fare.get("isStandardClass"): + continue + price_pence = fare.get("fare", 0) + if cheapest is None or price_pence < cheapest["price_pence"]: + cheapest = { + "ticket": fare.get("ticketType", ""), + "price": price_pence / 100, + "price_pence": price_pence, + "code": code, + } + if cheapest: + result[dep_time] = { + "ticket": cheapest["ticket"], + "price": cheapest["price"], + "code": cheapest["code"], + } return result + + +def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]: + """ + Fetch advance fares: cheapest standard advance and first-class advance per departure. + + Makes two sets of paginated API calls (standard class, then first class). + Returns {departure_time: {'advance_std': dict or None, 'advance_1st': dict or None}} + where each sub-dict has keys 'ticket', 'price', 'code'. + """ + std_advance: dict[str, dict] = {} + for dep_time, fares in _run_pages(station_crs, travel_date, first_class=False): + cheapest = None + for fare in fares: + code = fare.get("ticketTypeCode") + if code in _WALKON_CODES: + continue # skip walk-on fares + if not fare.get("isStandardClass"): + continue + price_pence = fare.get("fare", 0) + if cheapest is None or price_pence < cheapest["price_pence"]: + cheapest = { + "ticket": fare.get("ticketType", ""), + "price": price_pence / 100, + "price_pence": price_pence, + "code": code, + } + if cheapest: + std_advance[dep_time] = { + "ticket": cheapest["ticket"], + "price": cheapest["price"], + "code": cheapest["code"], + } + + first_advance: dict[str, dict] = {} + for dep_time, fares in _run_pages(station_crs, travel_date, first_class=True): + cheapest = None + for fare in fares: + price_pence = fare.get("fare", 0) + if cheapest is None or price_pence < cheapest["price_pence"]: + cheapest = { + "ticket": fare.get("ticketType", ""), + "price": price_pence / 100, + "price_pence": price_pence, + "code": fare.get("ticketTypeCode"), + } + if cheapest: + first_advance[dep_time] = { + "ticket": cheapest["ticket"], + "price": cheapest["price"], + "code": cheapest["code"], + } + + all_times = set(std_advance) | set(first_advance) + return { + t: { + "advance_std": std_advance.get(t), + "advance_1st": first_advance.get(t), + } + for t in all_times + } diff --git a/templates/base.html b/templates/base.html index 1ee3279..00e6724 100644 --- a/templates/base.html +++ b/templates/base.html @@ -248,8 +248,19 @@ .date-nav { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } .switcher-section { margin: 0.9rem 0 1rem; } .section-label { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.45rem; } - .filter-row { margin-top: 0.75rem; display: flex; gap: 1.5rem; align-items: center; } + .filter-row { margin-top: 0.75rem; display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; } .filter-label { font-size: 0.9rem; font-weight: 600; margin-right: 0.5rem; } + .btn-secondary { + padding: 0.3rem 0.75rem; + border: 1px solid #00539f; + border-radius: 4px; + color: #00539f; + background: #fff; + cursor: pointer; + font-size: 0.9rem; + } + .btn-secondary:hover { background: #f8fbff; } + .btn-secondary:disabled { opacity: 0.5; cursor: default; } .card-meta { color: #4a5568; margin: 0; } .footnote { margin-top: 1rem; font-size: 0.82rem; color: #718096; } diff --git a/templates/results.html b/templates/results.html index 8b1fd19..2645e3d 100644 --- a/templates/results.html +++ b/templates/results.html @@ -95,6 +95,28 @@ {% if trips or unreachable_morning_services %}
+ {% if trips %} +
+
+ + +
+
+ + +
+ +
+ {% endif %} @@ -108,11 +130,6 @@ {% if trips %} {% set best_mins = trips | map(attribute='total_minutes') | min %} {% 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 %} {% for row in result_rows %} {% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %} @@ -126,7 +143,16 @@ {% else %} {% set row_class = '' %} {% endif %} - + {% if row.row_type == 'trip' %} {% else %} {% endif %} @@ -221,7 +255,7 @@ Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min. GWR walk-on single prices from gwr.com. - Eurostar Standard prices are for 1 adult in GBP; always check + Eurostar Standard and Plus prices are for 1 adult in GBP; always check eurostar.com to book.  ·  {{ departure_station_name }} departures on RTT @@ -229,6 +263,117 @@ Paddington arrivals on RTT

+ + {% else %}

No valid journeys found.

diff --git a/tests/test_app.py b/tests/test_app.py index c25a2f5..08b97fd 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -33,6 +33,8 @@ def _stub_data(monkeypatch, prices=None, gwr_fares=None): 'train_number': 'ES 9014', 'price': p.get('price') if isinstance(p, dict) else None, 'seats': p.get('seats') if isinstance(p, dict) else None, + 'plus_price': p.get('plus_price') if isinstance(p, dict) else None, + 'plus_seats': p.get('plus_seats') if isinstance(p, dict) else None, }, ], ) @@ -173,7 +175,7 @@ def test_results_shows_only_pre_first_reachable_unreachable_services(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 walk-on + ES £59.00 _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}}) client = _client() @@ -182,7 +184,10 @@ def test_results_shows_eurostar_price_and_total(monkeypatch): assert resp.status_code == 200 assert '£59' in html # Eurostar Standard price - assert '£197.70' in html # Anytime £138.70 + ES £59 + assert '£138.70' in html # Walk-on price shown in NR cell + # Total (£197.70) is computed client-side; verify data attributes carry the right values + assert 'data-walkon="138.7"' in html + assert 'data-es-std="59"' in html def test_results_shows_unreachable_service_when_no_trips(monkeypatch): @@ -214,3 +219,119 @@ def test_results_shows_unreachable_service_when_no_trips(monkeypatch): assert 'ES 9001' in html assert 'Too early' in html assert 'No valid journeys found.' not in html + + +def test_results_shows_eurostar_plus_price(monkeypatch): + _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42, 'plus_price': 89, 'plus_seats': 5}}) + client = _client() + + resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + html = resp.get_data(as_text=True) + + assert resp.status_code == 200 + assert '£59' in html # Standard price + assert '£89' in html # Plus price + assert 'Plus' in html # Plus label + + +def test_results_selectors_present(monkeypatch): + _stub_data(monkeypatch) + client = _client() + + resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + html = resp.get_data(as_text=True) + + assert resp.status_code == 200 + assert 'nr-type-select' in html + assert 'es-type-select' in html + assert 'Load advance prices' in html + assert 'Plus' in html + + +def test_results_preloads_cached_advance_fares(monkeypatch): + advance_data = { + '07:00': { + 'advance_std': {'ticket': 'Advance Single', 'price': 45.0, 'code': 'ADV'}, + 'advance_1st': None, + } + } + def fake_get_cached(key, ttl=None): + if 'gwr_advance' in key: + return advance_data + return None + monkeypatch.setattr(app_module, 'get_cached', fake_get_cached) + monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr( + app_module.rtt_scraper, 'fetch', + lambda travel_date, user_agent, station_crs='BRI': [ + {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + ], + ) + monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {}) + monkeypatch.setattr( + app_module.eurostar_scraper, 'fetch', + lambda destination, travel_date: [ + {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', + 'destination': destination, 'train_number': 'ES 9014', + 'price': None, 'seats': None, 'plus_price': None, 'plus_seats': None}, + ], + ) + client = _client() + + resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') + html = resp.get_data(as_text=True) + + assert resp.status_code == 200 + # Cached advance fares are embedded in the page JS + assert '"advance_std"' in html + assert '45.0' in html + # Button is absent (hidden via cachedAdvanceFares check in JS) + # The JS will hide it on load; the data is present for applyAdvanceFares() + assert 'cachedAdvanceFares' in html + + +def test_api_advance_fares_returns_json(monkeypatch): + monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) + monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr( + app_module.gwr_fares_scraper, + 'fetch_advance', + lambda station_crs, travel_date: { + '07:00': { + 'advance_std': {'ticket': 'Advance Single', 'price': 45.0, 'code': 'ADV'}, + 'advance_1st': {'ticket': '1st Advance', 'price': 65.0, 'code': 'AFA'}, + } + }, + ) + client = _client() + + resp = client.get('/api/advance_fares/BRI/2026-04-10') + data = resp.get_json() + + assert resp.status_code == 200 + assert '07:00' in data + assert data['07:00']['advance_std']['price'] == 45.0 + assert data['07:00']['advance_1st']['price'] == 65.0 + + +def test_api_advance_fares_404_for_unknown_station(monkeypatch): + client = _client() + resp = client.get('/api/advance_fares/XYZ/2026-04-10') + assert resp.status_code == 404 + + +def test_api_advance_fares_returns_error_on_scraper_failure(monkeypatch): + monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) + monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr( + app_module.gwr_fares_scraper, + 'fetch_advance', + lambda s, d: (_ for _ in ()).throw(Exception('network error')), + ) + client = _client() + + resp = client.get('/api/advance_fares/BRI/2026-04-10') + data = resp.get_json() + + assert resp.status_code == 500 + assert 'error' in data diff --git a/tests/test_eurostar_scraper.py b/tests/test_eurostar_scraper.py index ed82358..b73597f 100644 --- a/tests/test_eurostar_scraper.py +++ b/tests/test_eurostar_scraper.py @@ -6,16 +6,25 @@ def _gql_response(journeys: list) -> dict: return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}} -def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES') -> dict: +def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES', + plus_price=None, plus_seats=None) -> dict: + fares = [{ + 'classOfService': {'code': 'STANDARD'}, + 'prices': {'displayPrice': price}, + 'seats': seats, + 'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}] + if service_name else [], + }] + if plus_price is not None or plus_seats is not None: + fares.append({ + 'classOfService': {'code': 'PLUS'}, + 'prices': {'displayPrice': plus_price}, + 'seats': plus_seats, + 'legs': [], + }) return { 'timing': {'departureTime': departs, 'arrivalTime': arrives}, - 'fares': [{ - 'classOfService': {'code': 'STANDARD'}, - 'prices': {'displayPrice': price}, - 'seats': seats, - 'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}] - if service_name else [], - }], + 'fares': fares, } @@ -34,6 +43,27 @@ def test_parse_graphql_single_journey(): assert s['train_number'] == 'ES 9014' assert s['price'] == 156.0 assert s['seats'] == 37 + assert s['plus_price'] is None + assert s['plus_seats'] is None + + +def test_parse_graphql_standard_premier_price(): + data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014', + plus_price=220, plus_seats=12)]) + services = _parse_graphql(data, 'Paris Gare du Nord') + assert len(services) == 1 + s = services[0] + assert s['price'] == 156.0 + assert s['seats'] == 37 + assert s['plus_price'] == 220.0 + assert s['plus_seats'] == 12 + + +def test_parse_graphql_plus_price_none_when_not_returned(): + data = _gql_response([_journey('09:31', '12:55', price=156, seats=37)]) + services = _parse_graphql(data, 'Paris Gare du Nord') + assert services[0]['plus_price'] is None + assert services[0]['plus_seats'] is None def test_parse_graphql_half_pound_price():
{{ row.depart_bristol }} → {{ row.arrive_paddington }} @@ -144,6 +170,12 @@ {% else %}
{% endif %} + +
{{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %} @@ -167,12 +199,14 @@ {% endif %} {% if row.eurostar_price is not none %}
£{{ "%.2f"|format(row.eurostar_price) }} - {% if row.eurostar_seats is not none %} - {{ row.eurostar_seats }} at this price - {% endif %} + Std{% if row.eurostar_seats is not none %} · {{ row.eurostar_seats }}{% endif %} {% else %}
{% endif %} + {% if row.eurostar_plus_price is not none %} +
£{{ "%.2f"|format(row.eurostar_plus_price) }} + Plus{% if row.eurostar_plus_seats is not none %} · {{ row.eurostar_plus_seats }}{% endif %} + {% endif %}
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %} @@ -182,9 +216,7 @@ {% else %} {{ row.total_duration }} {% endif %} - {% if row.total_price is not none %} -
£{{ "%.2f"|format(row.total_price) }}{% if min_price is defined and max_price is defined %}{% if row.total_price <= min_price + 10 %} 🪙{% elif row.total_price >= max_price - 10 %} 💸{% endif %}{% endif %} - {% endif %} +
@@ -202,12 +234,14 @@ {% endif %} {% if row.eurostar_price is not none %}
£{{ "%.2f"|format(row.eurostar_price) }} - {% if row.eurostar_seats is not none %} - {{ row.eurostar_seats }} at this price - {% endif %} + Std{% if row.eurostar_seats is not none %} · {{ row.eurostar_seats }}{% endif %} {% else %}
{% endif %} + {% if row.eurostar_plus_price is not none %} +
£{{ "%.2f"|format(row.eurostar_plus_price) }} + Plus{% if row.eurostar_plus_seats is not none %} · {{ row.eurostar_plus_seats }}{% endif %} + {% endif %}