diff --git a/.gitignore b/.gitignore index 8057dbb..b86fb2a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ venv/ # App cache/ +config/local.py # Pytest .pytest_cache/ diff --git a/README.md b/README.md index 5a53c28..ef56b9b 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 on Eurostar. +Plan a trip from Bristol Temple Meads to Europe via Eurostar. -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. +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. ## Destinations @@ -12,23 +12,42 @@ 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 are fetched from two sources simultaneously: +Train times and prices are fetched from two sources: - **GWR** — scraped from [Realtime Trains](https://www.realtimetrains.co.uk/) using httpx -- **Eurostar** — scraped from the Eurostar timetable pages via the embedded `__NEXT_DATA__` JSON (no browser required) +- **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. Results are cached to disk by date and destination. ## Connection constraints +Configurable via the search form. Defaults: + | | | |---|---| -| Minimum Paddington → St Pancras | 75 min | -| Maximum Paddington → St Pancras | 2h 20m | -| Maximum Bristol → Paddington | 1h 50m | +| 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. ## Setup @@ -36,6 +55,19 @@ Results are cached to disk by date and destination. 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 2d2a2e2..a5a60cb 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,7 +22,16 @@ RTT_BRISTOL_URL = ( "?stp=WVS&show=pax-calls&order=wtt" ) -app = Flask(__name__) +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'] DESTINATIONS = { 'paris': 'Paris Gare du Nord', @@ -30,6 +39,7 @@ DESTINATIONS = { 'lille': 'Lille Europe', 'amsterdam': 'Amsterdam Centraal', 'rotterdam': 'Rotterdam Centraal', + 'cologne': 'Cologne Hbf', } @@ -93,12 +103,10 @@ 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) - cached_prices = get_cached(prices_cache_key, ttl=24 * 3600) - from_cache = bool(cached_rtt and cached_es and cached_prices) + cached_es = get_cached(es_cache_key, ttl=24 * 3600) + from_cache = bool(cached_rtt and cached_es) error = None @@ -113,37 +121,36 @@ def results(slug, travel_date): error = f"Could not fetch GWR trains: {e}" if cached_es: - eurostar_trains = cached_es + eurostar_services = cached_es else: try: - eurostar_trains = eurostar_scraper.fetch(destination, travel_date, user_agent) - set_cached(es_cache_key, eurostar_trains) + eurostar_services = eurostar_scraper.fetch(destination, travel_date) + set_cached(es_cache_key, eurostar_services) except Exception as e: - eurostar_trains = [] + eurostar_services = [] msg = f"Could not fetch Eurostar times: {e}" error = f"{error}; {msg}" if error else msg - 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 + eurostar_trains = eurostar_services + eurostar_prices = { + s['depart_st_pancras']: {'price': s.get('price'), 'seats': s.get('seats')} + for s in eurostar_services + } 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, @@ -153,7 +160,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] @@ -186,6 +195,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, diff --git a/cache.py b/cache.py index 2e82500..31fad05 100644 --- a/cache.py +++ b/cache.py @@ -2,7 +2,7 @@ import json import os import time -CACHE_DIR = os.path.join(os.path.dirname(__file__), 'cache') +from config.default import CACHE_DIR # overridden by app config after import def _cache_path(key: str) -> str: diff --git a/circle_line.py b/circle_line.py new file mode 100644 index 0000000..5c942f9 --- /dev/null +++ b/circle_line.py @@ -0,0 +1,148 @@ +""" +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 new file mode 100644 index 0000000..e69de29 diff --git a/config/default.py b/config/default.py new file mode 100644 index 0000000..aff1369 --- /dev/null +++ b/config/default.py @@ -0,0 +1,10 @@ +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 4bbdfd9..8b9a881 100644 --- a/scraper/eurostar.py +++ b/scraper/eurostar.py @@ -1,29 +1,14 @@ """ -Scrape Eurostar timetable via httpx and fetch prices via the GraphQL API. +Fetch Eurostar timetable, prices, and seat availability via the GraphQL API. -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' -# Minimal query requesting only timing + Eurostar Standard fare price. -# Variable names and inline argument names match what the site sends so the +# Query requesting timing, train identity, and Standard fare price + seats. +# Variable names and argument names match the site's own query so the # server-side query planner sees a familiar shape. -_GQL_PRICES = ( +_GQL_QUERY = ( "query NewBookingSearch(" "$origin:String!,$destination:String!,$outbound:String!," "$currency:Currency!,$adult:Int," @@ -141,71 +55,103 @@ _GQL_PRICES = ( " hideExternalCarrierTrains:true" " hideDirectExternalCarrierTrains:true" "){" - "timing{departureTime:departs __typename}" + "timing{departureTime:departs arrivalTime:arrives}" "fares(filteredClassesOfService:$filteredClassesOfService){" - "classOfService{code __typename}" - "prices{displayPrice __typename}" - "seats __typename" + "classOfService{code}" + "prices{displayPrice}" + "seats " + "legs{serviceName serviceType{code}}" "}" - "__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 fetch_prices(destination: str, travel_date: str) -> dict[str, int | None]: +def _parse_graphql(data: dict, destination: str) -> list[dict]: """ - Return Eurostar Standard prices for every departure on travel_date. + Parse a NewBookingSearch GraphQL response into a list of service dicts. - Result: {depart_st_pancras: price_gbp_int_or_None} - None means the class is sold out or unavailable for that departure. + 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. """ 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_PRICES, + 'query': _GQL_QUERY, } resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20) resp.raise_for_status() - 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 + return _parse_graphql(resp.json(), destination) diff --git a/templates/base.html b/templates/base.html index aa65c78..5fb61b4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -185,9 +185,103 @@ .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 306fb90..8bdbb51 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,9 +1,9 @@ {% extends "base.html" %} {% block content %}