diff --git a/.gitignore b/.gitignore index b86fb2a..8057dbb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ venv/ # App cache/ -config/local.py # Pytest .pytest_cache/ diff --git a/README.md b/README.md index ef56b9b..5a53c28 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Source: https://git.4angle.com/edward/bristol-eurostar -Plan a trip from Bristol Temple Meads to Europe via Eurostar. +Plan a trip from Bristol Temple Meads to Europe on Eurostar. -Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras → destination) and shows all valid same-day connections, including Circle Line times for the Paddington → St Pancras transfer. Displays GWR walk-on fares, Eurostar Standard prices, seat availability, and total journey cost. +Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras → destination) and shows all valid same-day connections, filtering by journey time and minimum/maximum transfer window at Paddington/St Pancras. ## Destinations @@ -12,42 +12,23 @@ Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras - Brussels Midi - Lille Europe - Amsterdam Centraal -- Rotterdam Centraal -- Cologne Hbf ## How it works -Train times and prices are fetched from two sources: +Train times are fetched from two sources simultaneously: - **GWR** — scraped from [Realtime Trains](https://www.realtimetrains.co.uk/) using httpx -- **Eurostar** — fetched from the Eurostar GraphQL API (single call returns timetable, Standard fares, and seat availability) - -The Paddington → St Pancras transfer uses a real Circle Line timetable parsed from a TfL TransXChange XML file, accounting for walk time to the platform at Paddington and walk time from the platform to the St Pancras check-in. +- **Eurostar** — scraped from the Eurostar timetable pages via the embedded `__NEXT_DATA__` JSON (no browser required) Results are cached to disk by date and destination. ## Connection constraints -Configurable via the search form. Defaults: - | | | |---|---| -| Minimum Paddington → St Pancras | 50 min | -| Maximum Paddington → St Pancras | 110 min | - -Valid range: 45–120 min (min), 60–180 min (max). - -## GWR fares - -Walk-on single fares for Bristol Temple Meads → Paddington, selected automatically by departure time: - -| Ticket | Price | Restriction (weekdays) | -|---|---|---| -| Super Off-Peak | £45.00 | Not valid 05:05–09:57 | -| Off-Peak | £63.60 | Not valid before 08:26 | -| Anytime | £138.70 | No restriction | - -Weekends always use Super Off-Peak. +| Minimum Paddington → St Pancras | 75 min | +| Maximum Paddington → St Pancras | 2h 20m | +| Maximum Bristol → Paddington | 1h 50m | ## Setup @@ -55,19 +36,6 @@ Weekends always use Super Off-Peak. pip install -e ".[dev]" ``` -### Configuration - -Copy or create `config/local.py` (gitignored) to override defaults: - -```python -CACHE_DIR = '/var/cache/bristol-eurostar' -CIRCLE_LINE_XML = '/path/to/output_txc_01CIR_.xml' -``` - -Defaults (in `config/default.py`) use `~/lib/data/tfl/`. - -The Circle Line XML is a TfL TransXChange timetable file. The Paddington (H&C Line) stop is `9400ZZLUPAH1`; the King's Cross St Pancras stop is `9400ZZLUKSX3`. - ## Running ```bash diff --git a/app.py b/app.py index a5a60cb..2d2a2e2 100644 --- a/app.py +++ b/app.py @@ -3,12 +3,12 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination t """ from flask import Flask, render_template, redirect, url_for, request from datetime import date, timedelta -import os from cache import get_cached, set_cached import scraper.eurostar as eurostar_scraper import scraper.realtime_trains as rtt_scraper from trip_planner import combine_trips, find_unreachable_morning_eurostars +from scraper.eurostar import fetch_prices as fetch_eurostar_prices RTT_PADDINGTON_URL = ( "https://www.realtimetrains.co.uk/search/detailed/" @@ -22,16 +22,7 @@ RTT_BRISTOL_URL = ( "?stp=WVS&show=pax-calls&order=wtt" ) -app = Flask(__name__, instance_relative_config=False) -app.config.from_object('config.default') -_local = os.path.join(os.path.dirname(__file__), 'config', 'local.py') -if os.path.exists(_local): - app.config.from_pyfile(_local) - -import cache -import circle_line -cache.CACHE_DIR = app.config['CACHE_DIR'] -circle_line._TXC_XML = app.config['CIRCLE_LINE_XML'] +app = Flask(__name__) DESTINATIONS = { 'paris': 'Paris Gare du Nord', @@ -39,7 +30,6 @@ DESTINATIONS = { 'lille': 'Lille Europe', 'amsterdam': 'Amsterdam Centraal', 'rotterdam': 'Rotterdam Centraal', - 'cologne': 'Cologne Hbf', } @@ -103,10 +93,12 @@ def results(slug, travel_date): rtt_cache_key = f"rtt_{travel_date}" es_cache_key = f"eurostar_{travel_date}_{destination}" + prices_cache_key = f"eurostar_prices_{travel_date}_{destination}" cached_rtt = get_cached(rtt_cache_key) - cached_es = get_cached(es_cache_key, ttl=24 * 3600) - from_cache = bool(cached_rtt and cached_es) + cached_es = get_cached(es_cache_key) + cached_prices = get_cached(prices_cache_key, ttl=24 * 3600) + from_cache = bool(cached_rtt and cached_es and cached_prices) error = None @@ -121,36 +113,37 @@ def results(slug, travel_date): error = f"Could not fetch GWR trains: {e}" if cached_es: - eurostar_services = cached_es + eurostar_trains = cached_es else: try: - eurostar_services = eurostar_scraper.fetch(destination, travel_date) - set_cached(es_cache_key, eurostar_services) + eurostar_trains = eurostar_scraper.fetch(destination, travel_date, user_agent) + set_cached(es_cache_key, eurostar_trains) except Exception as e: - eurostar_services = [] + eurostar_trains = [] msg = f"Could not fetch Eurostar times: {e}" error = f"{error}; {msg}" if error else msg - eurostar_trains = eurostar_services - eurostar_prices = { - s['depart_st_pancras']: {'price': s.get('price'), 'seats': s.get('seats')} - for s in eurostar_services - } + if cached_prices: + eurostar_prices = cached_prices + else: + try: + eurostar_prices = fetch_eurostar_prices(destination, travel_date) + set_cached(prices_cache_key, eurostar_prices) + except Exception as e: + eurostar_prices = {} + msg = f"Could not fetch Eurostar prices: {e}" + error = f"{error}; {msg}" if error else msg trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection) - # Annotate each trip with Eurostar Standard price, seats, and total cost + # Annotate each trip with Eurostar Standard price and total cost for trip in trips: - es = eurostar_prices.get(trip['depart_st_pancras'], {}) - es_price = es.get('price') + es_price = eurostar_prices.get(trip['depart_st_pancras']) trip['eurostar_price'] = es_price - 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.' + if es_price is not None: + trip['total_price'] = trip['ticket_price'] + es_price + else: + trip['total_price'] = None unreachable_morning_services = find_unreachable_morning_eurostars( gwr_trains, @@ -160,9 +153,7 @@ def results(slug, travel_date): max_connection, ) for svc in unreachable_morning_services: - es = eurostar_prices.get(svc['depart_st_pancras'], {}) - svc['eurostar_price'] = es.get('price') - svc['eurostar_seats'] = es.get('seats') + svc['eurostar_price'] = eurostar_prices.get(svc['depart_st_pancras']) result_rows = sorted( [{'row_type': 'trip', **trip} for trip in trips] @@ -195,7 +186,6 @@ 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, diff --git a/cache.py b/cache.py index 31fad05..2e82500 100644 --- a/cache.py +++ b/cache.py @@ -2,7 +2,7 @@ import json import os import time -from config.default import CACHE_DIR # overridden by app config after import +CACHE_DIR = os.path.join(os.path.dirname(__file__), 'cache') def _cache_path(key: str) -> str: diff --git a/circle_line.py b/circle_line.py deleted file mode 100644 index 5c942f9..0000000 --- a/circle_line.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -Circle Line timetable: Paddington (H&C Line) → King's Cross St Pancras. - -Parses the TransXChange XML file on first use and caches the result in memory. -""" -import os -import re -import xml.etree.ElementTree as ET -from datetime import datetime, timedelta - -_PAD_STOP = '9400ZZLUPAH1' # Paddington (H&C Line) -_KXP_STOP = '9400ZZLUKSX3' # King's Cross St Pancras - -from config.default import CIRCLE_LINE_XML as _TXC_XML # overridden by app config after import -_NS = {'t': 'http://www.transxchange.org.uk/'} - -# Populated on first call to next_service(); maps day-type -> sorted list of -# (pad_depart_seconds, kxp_arrive_seconds) measured from midnight. -_timetable: dict[str, list[tuple[int, int]]] | None = None - - -def _parse_duration(s: str | None) -> int: - if not s: - return 0 - m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', s) - return int(m.group(1) or 0) * 3600 + int(m.group(2) or 0) * 60 + int(m.group(3) or 0) - - -def _load_timetable() -> dict[str, list[tuple[int, int]]]: - tree = ET.parse(_TXC_XML) - root = tree.getroot() - - # Build JPS id -> [(from_stop, to_stop, runtime_secs, wait_secs)] - jps_map: dict[str, list[tuple]] = {} - for jps_el in root.find('t:JourneyPatternSections', _NS): - links = [] - for link in jps_el.findall('t:JourneyPatternTimingLink', _NS): - fr = link.find('t:From/t:StopPointRef', _NS) - to = link.find('t:To/t:StopPointRef', _NS) - rt = link.find('t:RunTime', _NS) - wait = link.find('t:From/t:WaitTime', _NS) - links.append(( - fr.text if fr is not None else None, - to.text if to is not None else None, - _parse_duration(rt.text if rt is not None else None), - _parse_duration(wait.text if wait is not None else None), - )) - jps_map[jps_el.get('id')] = links - - def _seconds_to_depart(links, stop): - """Seconds from journey start until departure from *stop*.""" - elapsed = 0 - for fr, to, rt, wait in links: - elapsed += wait - if fr == stop: - return elapsed - elapsed += rt - return None - - def _seconds_to_arrive(links, stop): - """Seconds from journey start until arrival at *stop*.""" - elapsed = 0 - for fr, to, rt, wait in links: - elapsed += wait + rt - if to == stop: - return elapsed - return None - - # Map JP id -> (pad_offset_secs, kxp_arrive_offset_secs) - jp_offsets: dict[str, tuple[int, int]] = {} - for svc in root.find('t:Services', _NS): - for jp in svc.findall('.//t:JourneyPattern', _NS): - jps_ref = jp.find('t:JourneyPatternSectionRefs', _NS) - if jps_ref is None: - continue - links = jps_map.get(jps_ref.text, []) - stops = [l[0] for l in links] + ([links[-1][1]] if links else []) - if ( - _PAD_STOP in stops - and _KXP_STOP in stops - and stops.index(_PAD_STOP) < stops.index(_KXP_STOP) - ): - pad_off = _seconds_to_depart(links, _PAD_STOP) - kxp_off = _seconds_to_arrive(links, _KXP_STOP) - if pad_off is not None and kxp_off is not None: - jp_offsets[jp.get('id')] = (pad_off, kxp_off) - - result: dict[str, list[tuple[int, int]]] = { - 'MondayToFriday': [], - 'Saturday': [], - 'Sunday': [], - } - - for vj in root.find('t:VehicleJourneys', _NS): - jp_ref = vj.find('t:JourneyPatternRef', _NS) - dep_time = vj.find('t:DepartureTime', _NS) - op = vj.find('t:OperatingProfile', _NS) - if jp_ref is None or dep_time is None or jp_ref.text not in jp_offsets: - continue - pad_off, kxp_off = jp_offsets[jp_ref.text] - h, m, s = map(int, dep_time.text.split(':')) - dep_secs = h * 3600 + m * 60 + s - rdt = op.find('.//t:DaysOfWeek', _NS) if op is not None else None - if rdt is None: - continue - for day_el in rdt: - day_type = day_el.tag.split('}')[-1] - if day_type in result: - result[day_type].append((dep_secs + pad_off, dep_secs + kxp_off)) - - for key in result: - result[key].sort() - return result - - -def _get_timetable() -> dict[str, list[tuple[int, int]]]: - global _timetable - if _timetable is None: - _timetable = _load_timetable() - return _timetable - - -def _day_type(weekday: int) -> str: - if weekday < 5: - return 'MondayToFriday' - return 'Saturday' if weekday == 5 else 'Sunday' - - -def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None: - """ - Given the earliest time a passenger can board at Paddington (H&C Line), - return (circle_line_depart, arrive_kings_cross) as datetimes, or None if - no service is found before midnight. - - The caller is responsible for adding any walk time from the GWR platform - before passing *earliest_board*. - """ - timetable = _get_timetable()[_day_type(earliest_board.weekday())] - board_secs = ( - earliest_board.hour * 3600 - + earliest_board.minute * 60 - + earliest_board.second - ) - midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0) - for pad_secs, kxp_secs in timetable: - if pad_secs >= board_secs: - return midnight + timedelta(seconds=pad_secs), midnight + timedelta(seconds=kxp_secs) - return None diff --git a/config/__init__.py b/config/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/config/default.py b/config/default.py deleted file mode 100644 index aff1369..0000000 --- a/config/default.py +++ /dev/null @@ -1,10 +0,0 @@ -import os - -# Directory containing TfL reference data (TransXChange XML files etc.) -TFL_DATA_DIR = os.path.expanduser('~/lib/data/tfl') - -# Directory for caching scraped train times -CACHE_DIR = os.path.expanduser('~/lib/data/tfl/cache') - -# TransXChange timetable file for the Circle Line -CIRCLE_LINE_XML = os.path.join(TFL_DATA_DIR, 'output_txc_01CIR_.xml') diff --git a/scraper/eurostar.py b/scraper/eurostar.py index 8b9a881..4bbdfd9 100644 --- a/scraper/eurostar.py +++ b/scraper/eurostar.py @@ -1,14 +1,29 @@ """ -Fetch Eurostar timetable, prices, and seat availability via the GraphQL API. +Scrape Eurostar timetable via httpx and fetch prices via the GraphQL API. -A single POST to https://site-api.eurostar.com/gateway (operationName -NewBookingSearch) returns departure time, arrival time, train number, -Eurostar Standard fare price, and seats remaining at that price for every -service on the requested date. +Timetable: route-specific pages are Next.js SSR — all departure data is +embedded in ', html, re.DOTALL) + if not m: + return [] + data = json.loads(m.group(1)) + departures = data['props']['pageProps']['pageData']['liveDepartures'] + services = [] + for dep in departures: + dep_time = _hhmm(dep['origin']['model']['scheduledDepartureDateTime']) + arr_time = _hhmm(dep['destination']['model']['scheduledArrivalDateTime']) + if dep_time and arr_time: + carrier = dep.get('model', {}).get('carrier', 'ES') + number = dep.get('model', {}).get('trainNumber', '') + services.append({ + 'depart_st_pancras': dep_time, + 'arrive_destination': arr_time, + 'destination': destination, + 'train_number': f"{carrier} {number}" if number else '', + }) + return sorted(services, key=lambda s: s['depart_st_pancras']) + + +def fetch(destination: str, travel_date: str, + user_agent: str = DEFAULT_UA) -> list[dict]: + url = timetable_url(destination) + headers = { + 'User-Agent': user_agent, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-GB,en;q=0.9', + } + with httpx.Client(headers=headers, follow_redirects=True, timeout=20) as client: + r = client.get(url, params={'date': travel_date}) + r.raise_for_status() + return _parse(r.text, destination) + + +# --------------------------------------------------------------------------- +# Price fetching via site-api.eurostar.com GraphQL +# --------------------------------------------------------------------------- + _GATEWAY_URL = 'https://site-api.eurostar.com/gateway' -# Query requesting timing, train identity, and Standard fare price + seats. -# Variable names and argument names match the site's own query so the +# Minimal query requesting only timing + Eurostar Standard fare price. +# Variable names and inline argument names match what the site sends so the # server-side query planner sees a familiar shape. -_GQL_QUERY = ( +_GQL_PRICES = ( "query NewBookingSearch(" "$origin:String!,$destination:String!,$outbound:String!," "$currency:Currency!,$adult:Int," @@ -55,103 +141,71 @@ _GQL_QUERY = ( " hideExternalCarrierTrains:true" " hideDirectExternalCarrierTrains:true" "){" - "timing{departureTime:departs arrivalTime:arrives}" + "timing{departureTime:departs __typename}" "fares(filteredClassesOfService:$filteredClassesOfService){" - "classOfService{code}" - "prices{displayPrice}" - "seats " - "legs{serviceName serviceType{code}}" + "classOfService{code __typename}" + "prices{displayPrice __typename}" + "seats __typename" "}" + "__typename" "}" + "__typename" "}" + "__typename" "}" "}" ) -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 _generate_cid() -> str: chars = string.ascii_letters + string.digits return 'SRCH-' + ''.join(random.choices(chars, k=22)) -def _parse_graphql(data: dict, destination: str) -> list[dict]: +def fetch_prices(destination: str, travel_date: str) -> dict[str, int | None]: """ - Parse a NewBookingSearch GraphQL response into a list of service dicts. + Return Eurostar Standard prices for every departure on travel_date. - Each dict contains: depart_st_pancras, arrive_destination, destination, - train_number, price (float or None), seats (int or None). - - The same St Pancras departure can appear multiple times (different - connecting trains); we keep the entry with the earliest arrival. - Multi-leg train numbers are joined with ' + ' (e.g. 'ES 9116 + ER 9329'). - """ - best: dict[str, dict] = {} - journeys = data['data']['journeySearch']['outbound']['journeys'] - 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') - 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 - return sorted(best.values(), key=lambda s: s['depart_st_pancras']) - - -def fetch(destination: str, travel_date: str) -> list[dict]: - """ - Return all Eurostar services for destination on travel_date. - - Each dict contains timetable info (depart_st_pancras, arrive_destination, - train_number) plus pricing (price, seats) from a single GraphQL call. + Result: {depart_st_pancras: price_gbp_int_or_None} + None means the class is sold out or unavailable for that departure. """ dest_id = DESTINATION_STATION_IDS[destination] headers = { - 'User-Agent': DEFAULT_UA, - 'Content-Type': 'application/json', - 'Accept': '*/*', - 'Accept-Language':'en-GB', - 'Referer': 'https://www.eurostar.com/', - 'x-platform': 'web', - 'x-market-code': 'uk', - 'x-source-url': 'search-app/', - 'cid': _generate_cid(), + 'User-Agent': DEFAULT_UA, + 'Content-Type': 'application/json', + 'Accept': '*/*', + 'Accept-Language': 'en-GB', + 'Referer': 'https://www.eurostar.com/', + 'x-platform': 'web', + 'x-market-code': 'uk', + 'x-source-url': 'search-app/', + 'cid': _generate_cid(), } payload = { 'operationName': 'NewBookingSearch', 'variables': { - 'origin': ORIGIN_STATION_ID, - 'destination': dest_id, - 'outbound': travel_date, - 'currency': 'GBP', - 'adult': 1, - 'filteredClassesOfService': ['STANDARD'], + 'origin': ORIGIN_STATION_ID, + 'destination': dest_id, + 'outbound': travel_date, + 'currency': 'GBP', + 'adult': 1, + 'filteredClassesOfService': ['STANDARD'], }, - 'query': _GQL_QUERY, + 'query': _GQL_PRICES, } resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20) resp.raise_for_status() - return _parse_graphql(resp.json(), destination) + data = resp.json() + prices: dict[str, int | None] = {} + journeys = data['data']['journeySearch']['outbound']['journeys'] + for journey in journeys: + dep = journey['timing']['departureTime'] + price = 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']) + break + prices[dep] = price + return prices diff --git a/templates/base.html b/templates/base.html index 5fb61b4..aa65c78 100644 --- a/templates/base.html +++ b/templates/base.html @@ -185,103 +185,9 @@ .card { padding: 1.25rem; } - .col-transfer { display: none; } } a { color: #00539f; } - - /* Card helpers */ - .card > h2:first-child { margin-top: 0; } - .card-scroll { overflow-x: auto; } - - /* Form groups */ - .form-group { margin-bottom: 1.2rem; } - .form-group-lg { margin-bottom: 1.5rem; } - - /* Buttons */ - .btn-primary { - background: #00539f; - color: #fff; - border: none; - padding: 0.75rem 2rem; - font-size: 1rem; - font-weight: 600; - border-radius: 4px; - cursor: pointer; - } - - .btn-nav { - padding: 0.3rem 0.75rem; - border: 1px solid #cbd5e0; - border-radius: 4px; - text-decoration: none; - color: #00539f; - font-size: 0.9rem; - } - - /* Inline select */ - .select-inline { - padding: 0.3rem 0.6rem; - font-size: 0.9rem; - border: 1px solid #cbd5e0; - border-radius: 4px; - } - - /* Alert boxes */ - .alert { - margin-top: 1rem; - padding: 0.75rem 1rem; - border-radius: 4px; - } - .alert-error { - background: #fff5f5; - border: 1px solid #fc8181; - color: #c53030; - } - .alert-warning { - background: #fffbeb; - border: 1px solid #f6e05e; - color: #744210; - } - - /* Results page layout */ - .back-link { margin-bottom: 1rem; } - .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-label { font-size: 0.9rem; font-weight: 600; margin-right: 0.5rem; } - .card-meta { color: #4a5568; margin: 0; } - .footnote { margin-top: 1rem; font-size: 0.82rem; color: #718096; } - - /* Results table */ - .results-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; } - .results-table th, - .results-table td { padding: 0.6rem 0.8rem; } - .results-table thead tr { border-bottom: 2px solid #e2e8f0; text-align: left; } - .results-table tbody tr { border-bottom: 1px solid #e2e8f0; } - .row-fast { background: #f0fff4; } - .row-slow { background: #fff5f5; } - .row-alt { background: #f7fafc; } - .row-unreachable { background: #f7fafc; color: #a0aec0; } - - /* Empty state */ - .empty-state { color: #4a5568; text-align: center; padding: 3rem 2rem; } - .empty-state p { margin: 0; } - .empty-state p:first-child { font-size: 1.1rem; margin-bottom: 0.5rem; } - .empty-state p:last-child { font-size: 0.9rem; } - - /* Utilities */ - .text-muted { color: #718096; } - .text-dimmed { color: #a0aec0; } - .text-green { color: #276749; } - .text-red { color: #c53030; } - .text-blue { color: #00539f; } - .text-sm { font-size: 0.85rem; } - .text-xs { font-size: 0.75rem; } - .nowrap { white-space: nowrap; } - .font-bold { font-weight: 600; } - .font-normal { font-weight: 400; }
diff --git a/templates/index.html b/templates/index.html index 8bdbb51..306fb90 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,9 +1,9 @@ {% extends "base.html" %} {% block content %}