Compare commits

...

27 commits

Author SHA1 Message Date
ce6e8afcc8 Replace city name 'Cologne Hbf' with 'Cologne'. 2026-05-26 12:59:22 +01:00
21f84e2fb6 Select outbound date first for returns 2026-05-26 12:55:39 +01:00
2b475aa726 Clarify missing Eurostar price states 2026-05-26 12:55:32 +01:00
ed8a5626a4 Refine homepage journey form layout 2026-05-26 12:55:23 +01:00
13c4341f3a Add full type annotations and black formatting across all modules
Annotated all functions with mypy --strict-compatible types (-> None, dict[str,
Any], Generator types, etc.), added # type: ignore for untyped third-party libs
(lxml), and reformatted with black. All 18 source files now pass mypy --strict
with zero errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:48:53 +01:00
453d6244ec Stream results progressively via SSE instead of waiting for full render
The loading page now opens an EventSource to a new ?render=stream endpoint.
The server immediately sends a shell event (full page chrome: nav, filters,
JS — no external fetches needed), then a section event per direction as each
one's NR + Eurostar data arrives, and finally a done event with the summary
and timetable-refresh URL. The client slots each section card into a
placeholder and calls initialiseResultsPage() only after done, so fares and
advance-fare streaming start at the right moment.

Adds results_shell.html (shell template with empty JS data globals and
mergeSectionData/finaliseResults hooks), results_section.html (extracted
section card partial used by both the full and stream render paths), and
helper functions _section_trip_fares() and _build_summary_html() to avoid
duplicating fare-dict assembly between the two paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:24:40 +01:00
5f0d2c71b1 Cache walk-on NR fares by day of week for instant display
Store GWR walk-on fares keyed by timetable period + weekday
(weekday_gwr_fares_{direction}_{crs}_{period}_{day}), mirroring
the existing NR timetable weekday cache strategy.

On page load the server embeds any cached weekday fares in the page
as WALKON_CACHED_FARES so JS can populate prices immediately without
waiting for the GWR API. The live API call still runs afterwards to
verify and update any changed fares; the spinner label changes to
"Verifying fares" when cached prices are already shown.

The weekday cache is written whenever exact-date fares are fetched
from GWR, keeping it fresh, and populated lazily from the exact-date
cache on first access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:51:11 +01:00
1bc7631863 Five UI and data features for return journeys and results page
- Replace native date inputs with always-open custom calendar; return
  journeys show two months side-by-side with Airbnb-style range selection
- Add min-connection filter (30/40/50/60 min) for the inbound leg of
  return journeys, separate from the outbound connection filter
- Fix total journey time: naive datetime subtraction across CET/BST was
  1 h too long outbound and 1 h too short inbound
- Filter inbound circle line suggestions when connection ≥ 40 min: only
  show services arriving ≥ 5 min before GWR departure at Paddington
- Add Std / SP labels to Eurostar fare lines so users can distinguish
  Standard from Standard Premier
- Row selection with a fixed summary bar showing NR + Eurostar + circle
  totals; selection is preserved in the URL
- Load walk-on fares sequentially, outbound section first
- Mobile: card-grid table layout, hide headcode/platform on small screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:58:32 +01:00
a88b19fa4c Add (CET) label next to inbound Eurostar departure time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:27:09 +01:00
298ce14812 Add per-direction NR ticket and Eurostar class selectors for return journeys
Return pages now show separate NR/Eurostar fare class buttons for outbound
and inbound, so you can compare walk-on vs advance for each leg independently.
URL uses nr_class_out/nr_class_in/es_class_out/es_class_in params for returns;
single-direction pages keep the existing nr_class/es_class params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:17:57 +01:00
8d998aad71 Sticky table headers while scrolling within the table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:49:00 +01:00
2f554b9ca0 Relabel inbound circle line services: Circle (aim for) and next (fallback)
Consistent with outbound labelling. The first service is the one to aim
for if the Eurostar arrives slightly early; the second is dimmed as it
may not leave enough time for the GWR connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:48:14 +01:00
5910bae33b Show spinner while walk-on fares are loading
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:42:01 +01:00
03d2a53961 Stream GWR walk-on fares client-side instead of blocking page render
Walk-on fares are now always fetched in the browser via WALKON_API_URLS
rather than synchronously on the server. This means the page renders
immediately with timetable and Eurostar prices, and NR fares fill in
shortly after without delaying the initial load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:40:11 +01:00
b184e27a63 Show per-direction service counts for return journeys
Replace the combined "70 NR · 36 Eurostar" summary with separate
outbound/return lines so it's clear which counts belong to which leg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:47:33 +01:00
4194e8fa64 Improve return date UX on search form
Disable the return date input until Return journey type is selected.
Clicking anywhere in the return date group auto-selects Return and
enables the field. The return date min is kept in sync with the
outbound date, bumping the value forward if it would otherwise fall
before the outbound date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:42:39 +01:00
a859b96a23 Split return date nav into separate outbound/return rows; show earlier tube option on inbound
For return journeys, replace the single combined date navigation row with two
separate rows so outbound and return dates can be adjusted independently.

For inbound underground options, show one service before the earliest catchable
(as an "aim for this" option) rather than the next service after it, which
often arrived too late to connect with the GWR train.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:09:36 +01:00
1407cb8246 Hide Eurostar note during provisional loads 2026-05-21 12:27:15 +01:00
06e622d817 Refresh provisional results in place 2026-05-21 12:04:53 +01:00
bc7cb9cffa Cache provisional weekday timetables 2026-05-21 11:31:17 +01:00
378d2484d0 Improve progressive results loading 2026-05-21 09:52:58 +01:00
9691632f65 Add return and inbound journey support 2026-05-21 08:46:35 +01:00
6ba71447ef Add Codex marker file 2026-05-20 15:53:07 +01:00
a5023d0672 Stream advance fares and selectable ticket classes 2026-05-20 15:52:53 +01:00
2f3f01171c Add travel fare notes 2026-05-20 15:52:50 +01:00
0885019136 Add FastCGI entrypoint 2026-05-20 15:52:47 +01:00
9525059829 Add favicon asset 2026-05-20 15:52:45 +01:00
25 changed files with 6542 additions and 1085 deletions

0
.codex Normal file
View file

1676
app.py

File diff suppressed because it is too large Load diff

View file

@ -1,27 +1,35 @@
import json
import os
import time
import uuid
from typing import Any
from config.default import CACHE_DIR # overridden by app config after import
def _cache_path(key: str) -> str:
safe_key = key.replace('/', '_').replace(' ', '_')
safe_key = key.replace("/", "_").replace(" ", "_")
return os.path.join(CACHE_DIR, f"{safe_key}.json")
def get_cached(key: str, ttl: int | None = None):
def get_cached(key: str, ttl: int | None = None) -> Any:
"""Return cached data, or None if missing or older than ttl seconds."""
path = _cache_path(key)
if not os.path.exists(path):
try:
if not os.path.exists(path):
return None
if ttl is not None and time.time() - os.path.getmtime(path) > ttl:
return None
with open(path) as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
if ttl is not None and time.time() - os.path.getmtime(path) > ttl:
return None
with open(path) as f:
return json.load(f)
def set_cached(key: str, data) -> None:
def set_cached(key: str, data: Any) -> None:
os.makedirs(CACHE_DIR, exist_ok=True)
with open(_cache_path(key), 'w') as f:
path = _cache_path(key)
tmp_path = f"{path}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
with open(tmp_path, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp_path, path)

9
circle_line.md Normal file
View file

@ -0,0 +1,9 @@
The route from Paddington to Kings Cross St Pancras is via the Circle Line.
Departs every 10 minutes at these times past the hour: 08, 18, 28, 38, 48, 58
Journey time is 11 minutes.
Results could show what service London Underground service we expect to catch. Assume it takes 10 minutes to get from the GWR platform in Paddington station to the Paddington (H&C Line) Underground platform. Also takes 10 minutes to get from Kings Cross St Pancras platform to St Pancras check-in / security queue. Eurostar wants passengers to arrive at least 30 minutes before departure.
Can we encorporate this info somehow?

View file

@ -1,53 +1,69 @@
"""
Circle Line timetable: Paddington (H&C Line) King's Cross St Pancras.
Circle Line timetable between Paddington (H&C Line) and 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
from typing import Any
_PAD_STOP = '9400ZZLUPAH1' # Paddington (H&C Line)
_KXP_STOP = '9400ZZLUKSX3' # King's Cross St Pancras
_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/'}
from config.default import (
CIRCLE_LINE_XML as _TXC_XML,
) # overridden by app config after import
# 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
_NS = {"t": "http://www.transxchange.org.uk/"}
# Populated on first call to next_service(); maps direction -> day-type -> sorted
# list of (origin_depart_seconds, destination_arrive_seconds) measured from midnight.
_timetable: dict[str, 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)
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", s)
assert m is not None
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]]]:
def _load_timetable() -> dict[str, 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):
jps_map: dict[str, list[tuple[str | None, str | None, int, int]]] = {}
jps_sections = root.find("t:JourneyPatternSections", _NS)
assert jps_sections is not None
for jps_el in jps_sections:
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
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_id = jps_el.get("id")
assert jps_id is not None
jps_map[jps_id] = links
def _seconds_to_depart(links, stop):
def _seconds_to_depart(
links: list[tuple[str | None, str | None, int, int]], stop: str | None
) -> int | None:
"""Seconds from journey start until departure from *stop*."""
elapsed = 0
for fr, to, rt, wait in links:
@ -57,7 +73,9 @@ def _load_timetable() -> dict[str, list[tuple[int, int]]]:
elapsed += rt
return None
def _seconds_to_arrive(links, stop):
def _seconds_to_arrive(
links: list[tuple[str | None, str | None, int, int]], stop: str | None
) -> int | None:
"""Seconds from journey start until arrival at *stop*."""
elapsed = 0
for fr, to, rt, wait in links:
@ -66,15 +84,18 @@ def _load_timetable() -> dict[str, list[tuple[int, int]]]:
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)
# Map JP id -> [(direction, origin_depart_offset_secs, destination_arrive_offset_secs)].
jp_offsets: dict[str, list[tuple[str, int, int]]] = {}
services_el = root.find("t:Services", _NS)
assert services_el is not None
for svc in services_el:
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, [])
links = jps_map.get(jps_ref.text or "", [])
stops = [l[0] for l in links] + ([links[-1][1]] if links else [])
offsets = []
if (
_PAD_STOP in stops
and _KXP_STOP in stops
@ -83,37 +104,67 @@ def _load_timetable() -> dict[str, list[tuple[int, int]]]:
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)
offsets.append(("pad_to_kx", pad_off, kxp_off))
if (
_PAD_STOP in stops
and _KXP_STOP in stops
and stops.index(_KXP_STOP) < stops.index(_PAD_STOP)
):
kxp_off = _seconds_to_depart(links, _KXP_STOP)
pad_off = _seconds_to_arrive(links, _PAD_STOP)
if kxp_off is not None and pad_off is not None:
offsets.append(("kx_to_pad", kxp_off, pad_off))
if offsets:
jp_id = jp.get("id")
assert jp_id is not None
jp_offsets[jp_id] = offsets
result: dict[str, list[tuple[int, int]]] = {
'MondayToFriday': [],
'Saturday': [],
'Sunday': [],
result: dict[str, dict[str, list[tuple[int, int]]]] = {
"pad_to_kx": {
"MondayToFriday": [],
"Saturday": [],
"Sunday": [],
},
"kx_to_pad": {
"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)
vehicle_journeys = root.find("t:VehicleJourneys", _NS)
assert vehicle_journeys is not None
for vj in vehicle_journeys:
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(':'))
if dep_time.text is None:
continue
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
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))
day_type = day_el.tag.split("}")[-1]
for direction, origin_off, dest_off in jp_offsets[jp_ref.text]:
if day_type in result[direction]:
result[direction][day_type].append(
(
dep_secs + origin_off,
dep_secs + dest_off,
)
)
for key in result:
result[key].sort()
for direction in result:
for key in result[direction]:
result[direction][key].sort()
return result
def _get_timetable() -> dict[str, list[tuple[int, int]]]:
def _get_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]:
global _timetable
if _timetable is None:
_timetable = _load_timetable()
@ -122,11 +173,13 @@ def _get_timetable() -> dict[str, list[tuple[int, int]]]:
def _day_type(weekday: int) -> str:
if weekday < 5:
return 'MondayToFriday'
return 'Saturday' if weekday == 5 else 'Sunday'
return "MondayToFriday"
return "Saturday" if weekday == 5 else "Sunday"
def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None:
def next_service(
earliest_board: datetime, direction: str = "pad_to_kx"
) -> 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
@ -135,33 +188,39 @@ def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None:
The caller is responsible for adding any walk time from the GWR platform
before passing *earliest_board*.
"""
services = upcoming_services(earliest_board, count=1)
services = upcoming_services(earliest_board, count=1, direction=direction)
return services[0] if services else None
def upcoming_services(
earliest_board: datetime, count: int = 2
earliest_board: datetime,
count: int = 2,
direction: str = "pad_to_kx",
preceding: int = 0,
) -> list[tuple[datetime, datetime]]:
"""
Return up to *count* Circle line services from Paddington (H&C Line) to
King's Cross St Pancras, starting from *earliest_board*.
Return Circle line services for *direction* around *earliest_board*.
Each element is (depart_paddington, arrive_kings_cross) as datetimes.
Returns up to *preceding* services before earliest_board followed by up to
*count* services at or after earliest_board. Each element is
(depart_origin, arrive_destination) as datetimes.
"""
timetable = _get_timetable()[_day_type(earliest_board.weekday())]
timetable = _get_timetable().get(direction, {})[_day_type(earliest_board.weekday())]
board_secs = (
earliest_board.hour * 3600
+ earliest_board.minute * 60
+ earliest_board.second
earliest_board.hour * 3600 + earliest_board.minute * 60 + earliest_board.second
)
midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0)
pre_results = []
results = []
for pad_secs, kxp_secs in timetable:
if pad_secs >= board_secs:
results.append((
midnight + timedelta(seconds=pad_secs),
midnight + timedelta(seconds=kxp_secs),
))
entry = (
midnight + timedelta(seconds=pad_secs),
midnight + timedelta(seconds=kxp_secs),
)
if pad_secs < board_secs:
pre_results.append(entry)
else:
results.append(entry)
if len(results) == count:
break
return results
return pre_results[-preceding:] + results if preceding else results

View file

@ -1,13 +1,13 @@
import os
# Directory containing TfL reference data (TransXChange XML files etc.)
TFL_DATA_DIR = os.path.expanduser('~/lib/data/tfl')
TFL_DATA_DIR = os.path.expanduser("~/lib/data/tfl")
# Directory for caching scraped train times
CACHE_DIR = os.path.expanduser('~/lib/data/tfl/cache')
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')
CIRCLE_LINE_XML = os.path.join(TFL_DATA_DIR, "output_txc_01CIR_.xml")
# Default connection window (minutes) between Paddington arrival and St Pancras departure
DEFAULT_MIN_CONNECTION = 70

145
prices.md Normal file
View file

@ -0,0 +1,145 @@
Walk on fares for the Bristol Temple Meads to Paddington.
### Super off-peak single: £45
ticket code: SSS
Restricted Days: Mondays to Fridays
#### Outward Travel
Outward Travel; Not valid for travel on trains timed to depart after 04:29 and before the times shown from the following stations:
09:00 from Ashchurch;
09:10 from Avoncliff (if travel is via Bath Spa, the restriction time shown for Bath Spa also applies for journeys eastbound from this station);
10:00 from Bath Spa;
09:35 from Bradford on Avon (if travel is via Bath Spa, the restriction time shown for Bath Spa also applies for journeys eastbound from this station);
09:21 from Bridgwater;
10:11 from Bristol Parkway; (also valid between 02:00 and 05:30)
09:58 from Bristol Temple Meads; (also valid between 02:00 and 05:04)
09:40 from Castle Cary (if travel is via Bath Spa, the restriction time shown for Bath Spa also applies for journeys eastbound from this station);
09:15 from Cheltenham Spa (if travel is via Bristol Parkway, the restriction time shown for Bristol Parkway also applies for journeys eastbound from this station);
10:10 from Chippenham;
09:00 from Dawlish;
08:45 from Dawlish Warren;
09:20 from Exeter St Davids;
09:10 from Freshford (if travel is via Bath Spa, the restriction time shown for Bath Spa also applies for journeys eastbound from this station);
09:55 from Frome;
09:30 from Gloucester (if travel is via Bristol Parkway, the restriction time shown for Bristol Parkway also applies for journeys eastbound from this station);
09:00 from Highbridge;
09;00 from Ivybridge;
10:16 from Kemble;
09:10 from Nailsea & Backwell;
09:30 from Patchway;
10:30 from Pewsey;
08:45 from Starcross;
09:30 from Stonehouse;
09:50 from Stroud;
10:30 from Swindon; (also valid between 02:00 and 05:45)
09:50 from Taunton;
09:00 from Teignmouth;
09:35 from Tiverton Parkway;
09:23 from Trowbridge;
09:30 from Westbury (if travel is via Bath Spa, the restriction time shown for Bath Spa also applies for journeys eastbound from this station);
09:20 from Weston Milton;
09:00 from Weston-Super-Mare;
09:30 from Worle;
09:40 from Yatton.
Not valid on trains timed to arrive at Birmingham New Street before 10:15.
Not valid on trains timed to arrive at London Waterloo before 11:48.
### Seasonal Variations
GWR Christmas and New Year Travel Restrictions 2025/26
Up to and including Friday 19 December: Usual ticket restrictions apply.
Monday 22 to Wednesday 24 December:
Off-Peak tickets valid all day.
Super Off-Peak tickets subject to normal restriction times.
Monday 29 December to Friday 02 January \* No ticket restrictions apply.
Monday 05 January 2026 onwards Usual ticket restrictions apply.
Journey planners have been updated with the above information.
\* Please note this only applies to journeys priced by GWR. Flows within Wales, between Cheltenham and Gloucester and long-distance routes to areas not served by GWR are priced by other train companies, who may have different Christmas travel restrictions in place. Usual restrictions will apply to CPAY and may apply to other Operators services. Please check retail systems if unsure.
Customers using Off-Peak travel cards and arriving at London Paddington before 09:30 on weekdays, will need to wait until 09:30 for onward travel from Paddington.
### Off-peak single: £63.60
ticket code: SVS
Restricted Days: Mondays to Fridays
#### Outward Travel
Not valid for travel on trains timed to depart earlier than shown from the following stations:
07:46 from Ashchurch for Tewkesbury
08:20 from Avoncliff (if travel via Bath Spa, restriction time shown applies eastbound from this station)
08:39 from Bath Spa
08:20 from Bradford on Avon (if travel via Bath Spa, restriction time shown applies eastbound from this station)
07:10 from Bridgwater
08:55 from Bristol Parkway (also valid between 02:00 and 05:30)
08:26 from Bristol Temple Meads (also valid between 02:00 and 05:10)
08:30 from Castle Cary (if travel via Bath Spa, restriction time shown applies eastbound from this station)
08:21 from Cheltenham Spa (if travel is via Bristol Parkway, the restriction time shown for Bristol Parkway also applies for journeys eastbound from this station)
08:46 from Chippenham
07:25 from Dawlish
07:56 from Exeter St Davids (also valid on the 07:20 CrossCountry service, changing at Bristol Temple Meads or Parkway; also valid on the 07:44 service)
08:17 from Freshford (if travel via Bath Spa, restriction time shown applies eastbound from this station)
07:46 from Frome
08:26 from Gloucester (if travel is via Bristol Parkway, the restriction time shown for Bristol Parkway also applies for journeys eastbound from this station)
07:22 from Highbridge
09:05 from Kemble
08:30 from Keynsham
08:10 from Nailsea & Backwell
09:00 from Pewsey
08:31 from Patchway
09:07 from Slough
08:45 from Stonehouse
08:50 from Stroud
09:05 from Swindon (also valid between 02:00 and 05:45)
08:16 from Taunton (also valid on the 07:24 service via Bath Spa; also valid on the 07:46 CrossCountry service changing at Bristol Temple Meads or Parkway)
07:20 from Teignmouth
07:33 from Tiverton Parkway
08:06 from Trowbridge (if travel via Bath Spa, restriction time shown applies eastbound from this station)
08:56 from Westbury (if travel via Bath Spa, restriction time shown applies eastbound from this station)
07:56 from Weston Milton
07:45 from Weston-super-Mare
07:56 from Worle
07:55 from Yatton
Not valid on trains timed to arrive at London Waterloo after 04:29 and before 09:52.
Also not valid on trains that arrive at Birmingham New Street before 10:15.
#### Seasonal Variations
GWR Christmas and New Year Travel Restrictions 2025/26
Up to and including Friday 19 December: Usual ticket restrictions apply.
Monday 22 to Wednesday 24 December:
Off-Peak tickets valid all day.
Super Off-Peak tickets subject to normal restriction times.
Monday 29 December to Friday 02 January \* No ticket restrictions apply.
Monday 05 January 2026 onwards Usual ticket restrictions apply.
Journey planners have been updated with the above information.
\* Please note this only applies to journeys priced by GWR. Flows within Wales, between Cheltenham and Gloucester and long-distance routes to areas not served by GWR are priced by other train companies, who may have different Christmas travel restrictions in place. Usual restrictions will apply to CPAY and may apply to other Operators services. Please check retail systems if unsure.
Customers using Off-Peak travel cards and arriving at London Paddington before 09:30 on weekdays, will need to wait until 09:30 for onward travel from Paddington.
---
### Anytime day single: £138.70
ticket code: SDS
No restrictions

24
run.fcgi Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/python3
from flipflop import WSGIServer
import sys
sys.path.append('/home/edward/src/paddington-eurostar') # isort:skip
from app import app # isort:skip
class ScriptNameMiddleware:
def __init__(self, wsgi_app, script_name):
self.app = wsgi_app
self.script_name = script_name
def __call__(self, environ, start_response):
environ['SCRIPT_NAME'] = self.script_name
path_info = environ.get('PATH_INFO', '')
if path_info.startswith(self.script_name):
environ['PATH_INFO'] = path_info[len(self.script_name):]
return self.app(environ, start_response)
app.wsgi_app = ScriptNameMiddleware(app.wsgi_app, '/paddington-eurostar')
if __name__ == '__main__':
WSGIServer(app).run()

View file

@ -6,8 +6,10 @@ 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.
"""
import random
import string
from typing import Any
import requests
@ -16,18 +18,19 @@ DEFAULT_UA = (
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
ORIGIN_STATION_ID = '7015400'
ST_PANCRAS_STATION_ID = "7015400"
ORIGIN_STATION_ID = ST_PANCRAS_STATION_ID
DESTINATION_STATION_IDS = {
'Paris Gare du Nord': '8727100',
'Brussels Midi': '8814001',
'Lille Europe': '8722326',
'Amsterdam Centraal': '8400058',
'Rotterdam Centraal': '8400530',
'Cologne Hbf': '8015458',
"Paris Gare du Nord": "8727100",
"Brussels Midi": "8814001",
"Lille Europe": "8722326",
"Amsterdam Centraal": "8400058",
"Rotterdam Centraal": "8400530",
"Cologne Hbf": "8015458",
}
_GATEWAY_URL = 'https://site-api.eurostar.com/gateway'
_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
@ -35,13 +38,13 @@ _GATEWAY_URL = 'https://site-api.eurostar.com/gateway'
_GQL_QUERY = (
"query NewBookingSearch("
"$origin:String!,$destination:String!,$outbound:String!,"
"$currency:Currency!,$adult:Int,"
"$inbound:String,$currency:Currency!,$adult:Int,"
"$filteredClassesOfService:[ClassOfServiceEnum]"
"){"
"journeySearch("
"outboundDate:$outbound origin:$origin destination:$destination"
"outboundDate:$outbound inboundDate:$inbound origin:$origin destination:$destination"
" adults:$adult currency:$currency"
" productFamilies:[\"PUB\"] contractCode:\"EIL_ALL\""
' productFamilies:["PUB"] contractCode:"EIL_ALL"'
" adults16Plus:0 children:0 youths:0 children4Only:0 children5To11:0"
" infants:0 adultsWheelchair:0 childrenWheelchair:0 guideDogs:0"
" wheelchairCompanions:0 nonWheelchairCompanions:0"
@ -64,28 +67,59 @@ _GQL_QUERY = (
"}"
"}"
"}"
"inbound{"
"journeys("
"hideIndirectTrainsWhenDisruptedAndCancelled:false"
" hideDepartedTrains:true"
" hideExternalCarrierTrains:true"
" hideDirectExternalCarrierTrains:true"
"){"
"timing{departureTime:departs arrivalTime:arrives}"
"fares(filteredClassesOfService:$filteredClassesOfService){"
"classOfService{code}"
"prices{displayPrice}"
"seats "
"legs{serviceName serviceType{code}}"
"}"
"}"
"}"
"}"
"}"
)
_STANDARD = 'STANDARD'
_STANDARD_PLUS = 'PLUS'
_STANDARD = "STANDARD"
_STANDARD_PLUS = "PLUS"
def search_url(destination: str, travel_date: str) -> str:
def search_url(
destination: str,
travel_date: str,
direction: str = "outbound",
return_date: str | None = None,
) -> str:
dest_id = DESTINATION_STATION_IDS[destination]
origin = ST_PANCRAS_STATION_ID
destination_id = dest_id
outbound = travel_date
inbound = return_date
if direction == "inbound":
origin, destination_id = dest_id, ST_PANCRAS_STATION_ID
inbound = None
return (
f'https://www.eurostar.com/search/uk-en'
f'?adult=1&origin={ORIGIN_STATION_ID}&destination={dest_id}&outbound={travel_date}'
f"https://www.eurostar.com/search/uk-en"
f"?adult=1&origin={origin}&destination={destination_id}&outbound={outbound}"
+ (f"&inbound={inbound}" if inbound else "")
)
def _generate_cid() -> str:
chars = string.ascii_letters + string.digits
return 'SRCH-' + ''.join(random.choices(chars, k=22))
return "SRCH-" + "".join(random.choices(chars, k=22))
def _parse_graphql(data: dict, destination: str) -> list[dict]:
def _parse_journeys(
journeys: list[dict[str, Any]], destination: str, direction: str
) -> list[dict[str, Any]]:
"""
Parse a NewBookingSearch GraphQL response into a list of service dicts.
@ -96,43 +130,108 @@ def _parse_graphql(data: dict, destination: str) -> list[dict]:
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']
best: dict[str, dict[str, Any]] = {}
for journey in journeys:
dep = journey['timing']['departureTime']
arr = journey['timing']['arrivalTime']
dep = journey["timing"]["departureTime"]
arr = journey["timing"]["arrivalTime"]
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')
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(
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')
for leg in legs
if leg.get("serviceName")
)
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,
if direction == "inbound":
service = {
"depart_destination": dep,
"arrive_st_pancras": 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'])
key = dep
arrive_key = "arrive_st_pancras"
else:
service = {
"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,
}
key = dep
arrive_key = "arrive_destination"
if key not in best or arr < best[key][arrive_key]:
best[key] = service
sort_key = "depart_destination" if direction == "inbound" else "depart_st_pancras"
return sorted(best.values(), key=lambda s: s[sort_key])
def fetch(destination: str, travel_date: str) -> list[dict]:
def _parse_graphql(data: dict[str, Any], destination: str) -> list[dict[str, Any]]:
journeys = data["data"]["journeySearch"]["outbound"]["journeys"]
return _parse_journeys(journeys, destination, "outbound")
def _parse_graphql_leg(
data: dict[str, Any], destination: str, leg: str, direction: str
) -> list[dict[str, Any]]:
journeys = data["data"]["journeySearch"][leg]["journeys"]
return _parse_journeys(journeys, destination, direction)
def _payload(
origin: str, destination_id: str, outbound: str, inbound: str | None = None
) -> dict[str, Any]:
variables: dict[str, Any] = {
"origin": origin,
"destination": destination_id,
"outbound": outbound,
"inbound": inbound,
"currency": "GBP",
"adult": 1,
"filteredClassesOfService": [_STANDARD, _STANDARD_PLUS],
}
return {
"operationName": "NewBookingSearch",
"variables": variables,
"query": _GQL_QUERY,
}
def _headers() -> dict[str, str]:
return {
"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(),
}
def fetch(
destination: str, travel_date: str, direction: str = "outbound"
) -> list[dict[str, Any]]:
"""
Return all Eurostar services for destination on travel_date.
@ -140,29 +239,34 @@ def fetch(destination: str, travel_date: str) -> list[dict]:
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(),
}
payload = {
'operationName': 'NewBookingSearch',
'variables': {
'origin': ORIGIN_STATION_ID,
'destination': dest_id,
'outbound': travel_date,
'currency': 'GBP',
'adult': 1,
'filteredClassesOfService': [_STANDARD, _STANDARD_PLUS],
},
'query': _GQL_QUERY,
}
resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20)
if direction == "inbound":
origin, destination_id = dest_id, ST_PANCRAS_STATION_ID
else:
origin, destination_id = ST_PANCRAS_STATION_ID, dest_id
resp = requests.post(
_GATEWAY_URL,
json=_payload(origin, destination_id, travel_date),
headers=_headers(),
timeout=20,
)
resp.raise_for_status()
return _parse_graphql(resp.json(), destination)
leg_direction = "inbound" if direction == "inbound" else "outbound"
return _parse_graphql_leg(resp.json(), destination, "outbound", leg_direction)
def fetch_return(
destination: str, outbound_date: str, return_date: str
) -> dict[str, list[dict[str, Any]]]:
dest_id = DESTINATION_STATION_IDS[destination]
resp = requests.post(
_GATEWAY_URL,
json=_payload(ST_PANCRAS_STATION_ID, dest_id, outbound_date, return_date),
headers=_headers(),
timeout=20,
)
resp.raise_for_status()
data = resp.json()
return {
"outbound": _parse_graphql_leg(data, destination, "outbound", "outbound"),
"inbound": _parse_graphql_leg(data, destination, "inbound", "inbound"),
}

View file

@ -6,6 +6,8 @@ Returns per-train cheapest standard-class fare with restrictions already applied
Cache for 30 days fares rarely change.
"""
from typing import Any, Generator
import httpx
_API_URL = "https://api.gwr.com/api/shopping/journeysearch"
@ -16,7 +18,7 @@ _WALKON_CODES = {"SSS", "SVS", "SDS", "CDS"}
_MAX_PAGES = 20
def _headers() -> dict:
def _headers() -> dict[str, str]:
return {
"user-agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
@ -32,11 +34,12 @@ def _headers() -> dict:
def _request_body(
station_crs: str,
from_code: str,
to_code: str,
travel_date: str,
conversation_token: str | None,
later: bool,
) -> dict:
) -> dict[str, Any]:
return {
"IsNextOutward": False,
"IsPreviousOutward": False,
@ -44,8 +47,8 @@ def _request_body(
"IsPreviousReturn": False,
"campaignCode": "",
"validationCode": "",
"locfrom": f"GB{station_crs}",
"locto": _PAD_CODE,
"locfrom": from_code,
"locto": to_code,
"datetimedepart": f"{travel_date}T00:00:00",
"outwarddepartafter": True,
"datetimereturn": None,
@ -67,7 +70,22 @@ def _request_body(
}
def _run_pages(station_crs: str, travel_date: str, first_class: bool = False):
def _station_code(station_crs: str) -> str:
return f"GB{station_crs}"
def _od_codes(station_crs: str, direction: str) -> tuple[str, str]:
if direction == "from_paddington":
return _PAD_CODE, _station_code(station_crs)
return _station_code(station_crs), _PAD_CODE
def _run_pages(
station_crs: str,
travel_date: str,
first_class: bool = False,
direction: str = "to_paddington",
) -> Generator[tuple[str, list[Any]], None, None]:
"""
Iterate all pages of GWR journey search results.
@ -78,14 +96,17 @@ def _run_pages(station_crs: str, travel_date: str, first_class: bool = False):
with httpx.Client(headers=_headers(), timeout=30) as client:
conversation_token = None
later = False
from_code, to_code = _od_codes(station_crs, direction)
for _ in range(_MAX_PAGES):
body = _request_body(station_crs, travel_date, conversation_token, later)
body = _request_body(
from_code, to_code, 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", {})
data = resp.json().get("data") or {}
conversation_token = data.get("conversationToken")
for journey in data.get("outwardOpenPureReturnFare", []):
dep_iso = journey.get("departureTime", "")
@ -99,16 +120,59 @@ def _run_pages(station_crs: str, travel_date: str, first_class: bool = False):
later = True
def fetch(station_crs: str, travel_date: str) -> dict[str, dict]:
def _run_pages_batched(
station_crs: str,
travel_date: str,
first_class: bool = False,
direction: str = "to_paddington",
) -> Generator[list[tuple[str, list[Any]]], None, None]:
"""
Fetch GWR walk-on single fares from station_crs to London Paddington on travel_date.
Like _run_pages but yields one list of (dep_time, fares_list) per API page call,
allowing callers to stream results a page at a time.
"""
seen: set[str] = set()
with httpx.Client(headers=_headers(), timeout=30) as client:
conversation_token = None
later = False
from_code, to_code = _od_codes(station_crs, direction)
for _ in range(_MAX_PAGES):
body = _request_body(
from_code, to_code, 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") or {}
conversation_token = data.get("conversationToken")
batch = []
for journey in data.get("outwardOpenPureReturnFare", []):
dep_iso = journey.get("departureTime", "")
dep_time = dep_iso[11:16]
if not dep_time or dep_time in seen:
continue
seen.add(dep_time)
batch.append((dep_time, journey.get("journeyFareDetails", [])))
if batch:
yield batch
if not data.get("showLaterOutward", False):
break
later = True
def fetch(
station_crs: str, travel_date: str, direction: str = "to_paddington"
) -> dict[str, dict[str, Any]]:
"""
Fetch GWR walk-on single fares for the selected Paddington direction.
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):
result: dict[str, dict[str, Any]] = {}
for dep_time, fares in _run_pages(station_crs, travel_date, direction=direction):
cheapest = None
for fare in fares:
code = fare.get("ticketTypeCode")
@ -133,7 +197,9 @@ def fetch(station_crs: str, travel_date: str) -> dict[str, dict]:
return result
def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]:
def fetch_advance(
station_crs: str, travel_date: str, direction: str = "to_paddington"
) -> dict[str, dict[str, Any]]:
"""
Fetch advance fares: cheapest standard advance and first-class advance per departure.
@ -141,8 +207,10 @@ def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]:
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):
std_advance: dict[str, dict[str, Any]] = {}
for dep_time, fares in _run_pages(
station_crs, travel_date, first_class=False, direction=direction
):
cheapest = None
for fare in fares:
code = fare.get("ticketTypeCode")
@ -165,8 +233,10 @@ def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]:
"code": cheapest["code"],
}
first_advance: dict[str, dict] = {}
for dep_time, fares in _run_pages(station_crs, travel_date, first_class=True):
first_advance: dict[str, dict[str, Any]] = {}
for dep_time, fares in _run_pages(
station_crs, travel_date, first_class=True, direction=direction
):
cheapest = None
for fare in fares:
price_pence = fare.get("fare", 0)
@ -192,3 +262,75 @@ def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]:
}
for t in all_times
}
def fetch_advance_streaming(
station_crs: str, travel_date: str, direction: str = "to_paddington"
) -> Generator[dict[str, dict[str, Any]], None, None]:
"""
Generator yielding partial advance fare dicts one GWR API page at a time.
Each yield is {dep_time: {'advance_std': dict|None, 'advance_1st': dict|None}}.
Two passes are made (standard class then first class); each page of results is
yielded immediately so callers can stream prices to clients as they arrive.
"""
# Pass 1: standard class advance fares
for batch in _run_pages_batched(
station_crs, travel_date, first_class=False, direction=direction
):
page: dict[str, dict[str, Any]] = {}
for dep_time, fares in batch:
cheapest = None
for fare in fares:
code = fare.get("ticketTypeCode")
if code 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:
page[dep_time] = {
"advance_std": {
"ticket": cheapest["ticket"],
"price": cheapest["price"],
"code": cheapest["code"],
},
"advance_1st": None,
}
if page:
yield page
# Pass 2: first class advance fares
for batch in _run_pages_batched(
station_crs, travel_date, first_class=True, direction=direction
):
page = {}
for dep_time, fares in batch:
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:
page[dep_time] = {
"advance_std": None,
"advance_1st": {
"ticket": cheapest["ticket"],
"price": cheapest["price"],
"code": cheapest["code"],
},
}
if page:
yield page

View file

@ -1,14 +1,18 @@
"""
Scrape GWR trains from Bristol Temple Meads to London Paddington using Realtime Trains.
Scrape direct trains between a selected station and London Paddington using
Realtime Trains.
Two fetches:
BRI/to/PAD departure times from Bristol (div.time.plan.d)
PAD/from/BRI arrival times at Paddington (div.time.plan.a)
Matched by train ID (div.tid).
"""
import re
from typing import Any
import httpx
import lxml.html
import lxml.html # type: ignore[import-untyped]
_TO_PAD_TMPL = (
"https://www.realtimetrains.co.uk/search/detailed/"
@ -20,6 +24,16 @@ _PAD_FROM_TMPL = (
"gb-nr:PAD/from/gb-nr:{crs}/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt"
)
_PAD_TO_TMPL = (
"https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:PAD/to/gb-nr:{crs}/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt"
)
_FROM_PAD_TMPL = (
"https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:{crs}/from/gb-nr:PAD/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt"
)
DEFAULT_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
@ -27,7 +41,7 @@ DEFAULT_UA = (
)
def _browser_headers(user_agent: str) -> dict:
def _browser_headers(user_agent: str) -> dict[str, str]:
return {
"User-Agent": user_agent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
@ -44,7 +58,7 @@ def _browser_headers(user_agent: str) -> dict:
def _fmt(hhmm: str) -> str:
"""Convert '0830''08:30'."""
hhmm = re.sub(r'[^0-9]', '', hhmm)
hhmm = re.sub(r"[^0-9]", "", hhmm)
if len(hhmm) == 4:
return f"{hhmm[:2]}:{hhmm[2:]}"
return hhmm
@ -53,12 +67,12 @@ def _fmt(hhmm: str) -> str:
def _parse_services(html: str, time_selector: str) -> dict[str, str]:
"""Return {train_id: time_string} from a servicelist page."""
root = lxml.html.fromstring(html)
sl = root.cssselect('div.servicelist')
sl = root.cssselect("div.servicelist")
if not sl:
return {}
result = {}
for svc in sl[0].cssselect('a.service'):
tid_els = svc.cssselect('div.tid')
for svc in sl[0].cssselect("a.service"):
tid_els = svc.cssselect("div.tid")
time_els = svc.cssselect(time_selector)
if tid_els and time_els:
tid = tid_els[0].text_content().strip()
@ -68,48 +82,91 @@ def _parse_services(html: str, time_selector: str) -> dict[str, str]:
return result
def _parse_arrivals(html: str) -> dict[str, dict]:
"""Return {train_id: {'time': ..., 'platform': ...}} from a PAD arrivals page."""
def _parse_arrivals(html: str) -> dict[str, dict[str, str]]:
"""Return {train_id: {'time': ..., 'platform': ...}} from an arrivals page."""
root = lxml.html.fromstring(html)
sl = root.cssselect('div.servicelist')
sl = root.cssselect("div.servicelist")
if not sl:
return {}
result = {}
for svc in sl[0].cssselect('a.service'):
tid_els = svc.cssselect('div.tid')
time_els = svc.cssselect('div.time.plan.a')
for svc in sl[0].cssselect("a.service"):
tid_els = svc.cssselect("div.tid")
time_els = svc.cssselect("div.time.plan.a")
if not (tid_els and time_els):
continue
time_text = time_els[0].text_content().strip()
if not time_text:
continue
plat_els = svc.cssselect('div.platform')
platform = plat_els[0].text_content().strip() if plat_els else ''
plat_els = svc.cssselect("div.platform")
platform = plat_els[0].text_content().strip() if plat_els else ""
result[tid_els[0].text_content().strip()] = {
'time': _fmt(time_text),
'platform': platform,
"time": _fmt(time_text),
"platform": platform,
}
return result
def fetch(date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI') -> list[dict]:
"""Fetch trains from station_crs to PAD; returns [{'depart_bristol', 'arrive_paddington', 'headcode', 'arrive_platform'}]."""
def fetch(
date: str, user_agent: str = DEFAULT_UA, station_crs: str = "BRI"
) -> list[dict[str, Any]]:
"""Fetch trains from station_crs to PAD."""
headers = _browser_headers(user_agent)
with httpx.Client(headers=headers, follow_redirects=True, timeout=30) as client:
r_bri = client.get(_TO_PAD_TMPL.format(crs=station_crs, date=date))
r_pad = client.get(_PAD_FROM_TMPL.format(crs=station_crs, date=date))
departures = _parse_services(r_bri.text, 'div.time.plan.d')
arrivals = _parse_arrivals(r_pad.text)
departures = _parse_services(r_bri.text, "div.time.plan.d")
arrivals = _parse_arrivals(r_pad.text)
trains = [
{
'depart_bristol': dep,
'arrive_paddington': arrivals[tid]['time'],
'arrive_platform': arrivals[tid]['platform'],
'headcode': tid,
"depart_bristol": dep,
"arrive_paddington": arrivals[tid]["time"],
"arrive_platform": arrivals[tid]["platform"],
"headcode": tid,
}
for tid, dep in departures.items()
if tid in arrivals
]
return sorted(trains, key=lambda t: t['depart_bristol'])
return sorted(trains, key=lambda t: t["depart_bristol"])
def fetch_to_paddington(
date: str, user_agent: str = DEFAULT_UA, station_crs: str = "BRI"
) -> list[dict[str, Any]]:
"""Fetch trains from station_crs to PAD using generic field names."""
return [
{
**train,
"depart_origin": train["depart_bristol"],
"arrive_paddington": train["arrive_paddington"],
"arrive_platform": train.get("arrive_platform", ""),
"headcode": train.get("headcode", ""),
}
for train in fetch(date, user_agent, station_crs)
]
def fetch_from_paddington(
date: str, user_agent: str = DEFAULT_UA, station_crs: str = "BRI"
) -> list[dict[str, Any]]:
"""Fetch trains from PAD to station_crs."""
headers = _browser_headers(user_agent)
with httpx.Client(headers=headers, follow_redirects=True, timeout=30) as client:
r_pad = client.get(_PAD_TO_TMPL.format(crs=station_crs, date=date))
r_station = client.get(_FROM_PAD_TMPL.format(crs=station_crs, date=date))
departures = _parse_services(r_pad.text, "div.time.plan.d")
arrivals = _parse_arrivals(r_station.text)
trains = [
{
"depart_paddington": dep,
"arrive_destination": arrivals[tid]["time"],
"arrive_platform": arrivals[tid]["platform"],
"headcode": tid,
}
for tid, dep in departures.items()
if tid in arrivals
]
return sorted(trains, key=lambda t: t["depart_paddington"])

14
static/favicon.svg Normal file
View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Background circle -->
<circle cx="16" cy="16" r="15" fill="#00539f"/>
<!-- Train body -->
<rect x="6" y="12" width="20" height="10" rx="2" fill="#fff"/>
<!-- Train windows -->
<rect x="9" y="14" width="4" height="4" rx="1" fill="#00539f"/>
<rect x="15" y="14" width="4" height="4" rx="1" fill="#00539f"/>
<!-- Train nose -->
<path d="M26 14 L30 16 L26 18 Z" fill="#fff"/>
<!-- Wheels -->
<circle cx="10" cy="23" r="2" fill="#fff"/>
<circle cx="22" cy="23" r="2" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 573 B

View file

@ -95,6 +95,10 @@
gap: 0.75rem;
}
.destination-grid--eurostar {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.destination-option {
position: relative;
}
@ -110,7 +114,7 @@
.destination-option label {
display: block;
min-height: 100%;
padding: 0.95rem 1rem;
padding: 0.8rem 0.85rem;
border: 1px solid #cbd5e0;
border-radius: 10px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
@ -121,7 +125,7 @@
.destination-option label strong {
display: block;
color: #0f172a;
font-size: 1rem;
font-size: 0.98rem;
margin-bottom: 0.2rem;
}
@ -182,10 +186,51 @@
}
@media (max-width: 640px) {
.card {
padding: 1.25rem;
.card { padding: 1.25rem; }
.destination-grid--eurostar {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.col-transfer { display: none; }
/* Convert results table to a 2-column card layout per row */
.results-table, .results-table tbody { display: block; }
.results-table thead { display: none; }
.results-table tr {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 2px solid #e2e8f0;
padding: 0.1rem 0;
}
.results-table td { padding: 0.35rem 0.45rem; font-size: 0.8rem; border-bottom: none; }
/* First journey leg (NR outbound / Eurostar inbound) */
.results-table td:nth-child(1) { grid-column: 1; grid-row: 1; }
/* Transfer column: hidden */
.col-transfer { display: none !important; }
/* Second journey leg */
.results-table td:nth-child(3) { grid-column: 2; grid-row: 1; }
/* Total: spans both columns, right-aligned */
.results-table td:nth-child(4) {
grid-column: 1 / -1; grid-row: 2;
text-align: right;
border-top: 1px solid #e2e8f0;
padding: 0.25rem 0.45rem 0.3rem;
}
/* Hide non-essential detail on mobile */
.mobile-hide { display: none !important; }
.fare-seats { display: none !important; }
/* Show connection time hint */
.mobile-conn { display: block !important; }
/* Flow arrow: hide on mobile */
.results-table thead th.flow-step::after { display: none; }
.results-table thead th.flow-step { padding-right: 0; }
/* Selection bar: smaller on mobile */
#selection-bar { padding: 0.5rem 0.75rem; font-size: 0.8rem; }
.sel-totals { gap: 0.75rem; }
}
a { color: #00539f; }
@ -196,6 +241,22 @@
/* Form groups */
.form-group { margin-bottom: 1.2rem; }
.form-group-lg { margin-bottom: 1.5rem; }
.form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.form-row .form-group,
.form-row .form-group-lg {
margin-bottom: 0;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
#return-date-group:has(input:disabled) { cursor: pointer; }
/* Buttons */
.btn-primary {
@ -246,6 +307,7 @@
/* Results page layout */
.back-link { margin-bottom: 1rem; }
.date-nav { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.date-nav-label { min-width: 6rem; font-weight: 600; font-size: 0.9rem; }
.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; flex-wrap: wrap; }
@ -269,11 +331,47 @@
.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 th { position: sticky; top: 0; background: #fff; z-index: 1; }
.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; }
.row-selected { background: #ebf8ff !important; }
tr.row-selectable { cursor: pointer; }
tr.row-selectable:hover:not(.row-selected) { filter: brightness(0.97); }
/* Journey flow arrow between column headers */
.results-table thead th.flow-step { position: relative; padding-right: 1.4rem; }
.results-table thead th.flow-step::after {
content: '';
position: absolute; right: 0.2rem; top: 50%; transform: translateY(-50%);
color: #cbd5e0; font-size: 1.5rem; font-weight: 300; line-height: 1;
}
/* Mobile: hidden by default, shown on mobile */
.mobile-conn { display: none; }
.fare-seats { display: inline; }
/* Selection summary bar */
#selection-bar {
display: none; position: fixed; bottom: 0; left: 0; right: 0;
background: #fff; border-top: 2px solid #00539f;
padding: 0.65rem 1rem;
box-shadow: 0 -2px 10px rgba(0,0,0,0.12); z-index: 200;
font-size: 0.88rem;
}
.sel-bar-inner {
max-width: 1100px; margin: 0 auto;
display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 0.5rem;
}
.sel-totals { display: flex; gap: 1.25rem; align-items: center; flex-wrap: wrap; }
.sel-clear {
background: none; border: 1px solid #cbd5e0; border-radius: 4px;
padding: 0.2rem 0.6rem; font-size: 0.8rem; cursor: pointer; color: #718096;
}
.sel-clear:hover { background: #f0f4f8; }
/* Empty state */
.empty-state { color: #4a5568; text-align: center; padding: 3rem 2rem; }
@ -281,6 +379,51 @@
.empty-state p:first-child { font-size: 1.1rem; margin-bottom: 0.5rem; }
.empty-state p:last-child { font-size: 0.9rem; }
/* Ticket class button group */
.btn-group { display: inline-flex; border: 1px solid #cbd5e0; border-radius: 4px; overflow: hidden; vertical-align: middle; }
.btn-group-option { padding: 0.28rem 0.65rem; font-size: 0.82rem; background: #fff; border: none; border-right: 1px solid #cbd5e0; cursor: pointer; color: #374151; white-space: nowrap; }
.btn-group-option:last-child { border-right: none; }
.btn-group-option.active { background: #00539f; color: #fff; font-weight: 600; }
.btn-group-option:hover:not(.active) { background: #f0f4f8; }
/* Flash animation for total price */
@keyframes price-flash { 0%,100% { background-color: transparent; } 40% { background-color: #fef08a; } }
.price-flash { animation: price-flash 0.7s ease-out; border-radius: 3px; }
/* Loading state */
#advance-loading { font-size: 0.82rem; color: #718096; margin-left: 0.5rem; }
.loading-panel {
display: flex;
gap: 1rem;
align-items: flex-start;
margin-top: 1rem;
padding: 1rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
background: #f8fbff;
}
.loading-panel p { margin: 0.25rem 0 0; }
.spinner {
width: 1.5rem;
height: 1.5rem;
border: 3px solid #cbd5e0;
border-top-color: #00539f;
border-radius: 50%;
flex: 0 0 auto;
animation: spin 0.8s linear infinite;
}
.spinner-inline {
width: 0.85rem;
height: 0.85rem;
border-width: 2px;
margin-right: 0.35rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Fare lines — show all, dim inactive */
.fare-line { display: block; line-height: 1.6; transition: opacity 0.15s; }
.fare-inactive { opacity: 0.4; }
/* Utilities */
.text-muted { color: #718096; }
.text-dimmed { color: #a0aec0; }

View file

@ -1,8 +1,75 @@
{% extends "base.html" %}
{% block content %}
<style>
/* ── Calendar ───────────────────────────────────────────────────── */
.cal-wrap { margin-top: .5rem; }
.cal-nav {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: .9rem;
}
.cal-nav-btn {
flex-shrink: 0; width: 2rem; height: 2rem;
border: 1px solid #e2e8f0; border-radius: 50%;
background: #fff; cursor: pointer;
font-size: 1.3rem; line-height: 1; color: #4a5568;
display: flex; align-items: center; justify-content: center;
transition: background .12s;
}
.cal-nav-btn:hover:not(:disabled) { background: #f0f4f8; }
.cal-nav-btn:disabled { opacity: .3; cursor: default; }
#cal-titles {
display: flex; flex: 1; gap: 0;
margin: 0 .5rem;
font-weight: 600; font-size: .9rem; color: #1a202c;
}
#cal-titles span { flex: 1; text-align: center; }
#cal-months { display: flex; gap: 2rem; }
.cal-month { flex: 1; min-width: 0; }
.cal-grid {
display: grid; grid-template-columns: repeat(7, 1fr);
}
.cal-dow {
text-align: center; font-size: .72rem; font-weight: 600;
color: #718096; padding: .4rem 0 .25rem;
}
.cal-cell { padding: 1px 0; } /* background set inline for range */
.cal-btn {
width: 100%; aspect-ratio: 1;
max-width: 2.4rem; max-height: 2.4rem;
border: none; background: none; cursor: pointer;
font-size: .875rem; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
margin: 0 auto;
transition: background .1s;
font-family: inherit; color: #1a202c;
}
.cal-btn:hover:not(:disabled):not(.cal-selected) { background: #e2e8f0; }
.cal-btn.cal-past { color: #d1d5db !important; cursor: default; }
.cal-btn.cal-today { color: #00539f; font-weight: 700; }
.cal-btn.cal-selected {
background: #00539f !important; color: #fff !important; font-weight: 600;
}
/* subtly darken the in-range hover */
.cal-cell.cal-in-range .cal-btn:hover:not(:disabled) { background: #bfdbfe; }
.cal-hint { margin-top: .75rem; font-size: .88rem; color: #4a5568; min-height: 1.4rem; }
.cal-cta { color: #00539f; font-weight: 600; }
@media (max-width: 620px) {
#cal-months { flex-direction: column; gap: 1.25rem; }
#cal-titles { gap: 0; }
}
</style>
<div class="card">
<h2>Plan your journey</h2>
<form method="get" action="{{ url_for('search') }}">
<form method="get" action="{{ url_for('search') }}" id="search-form">
<div class="form-group-lg">
<label for="station_crs" class="field-label">Departure point</label>
<select id="station_crs" name="station_crs" class="form-control">
@ -12,62 +79,404 @@
</select>
</div>
<div class="form-group">
<span class="field-label">Journey type</span>
<div class="destination-grid" role="radiogroup" aria-label="Journey type">
<div class="destination-option">
<input type="radio" id="journey-outbound" name="journey_type" value="outbound" checked>
<label for="journey-outbound"><strong>Out</strong><span>UK to Europe</span></label>
</div>
<div class="destination-option">
<input type="radio" id="journey-inbound" name="journey_type" value="inbound">
<label for="journey-inbound"><strong>Back</strong><span>Europe to UK</span></label>
</div>
<div class="destination-option">
<input type="radio" id="journey-return" name="journey_type" value="return">
<label for="journey-return"><strong>Return</strong><span>Out and back</span></label>
</div>
</div>
</div>
<div class="form-group">
<span class="field-label">Eurostar destination</span>
<div class="destination-grid" role="radiogroup" aria-label="Eurostar destination">
{% for slug, name in destinations.items() %}
{% set city = name.replace(' Gare du Nord', '').replace(' Centraal', '').replace(' Midi', '').replace(' Europe', '') %}
<div class="destination-grid destination-grid--eurostar" role="radiogroup" aria-label="Eurostar destination">
{% for destination in destination_options %}
<div class="destination-option">
<input
type="radio"
id="destination-{{ slug }}"
name="destination"
value="{{ slug }}"
{% if loop.first %}checked{% endif %}
required>
<label for="destination-{{ slug }}">
<strong>{{ city }}</strong>
<span>{{ name }}</span>
</label>
<input type="radio" id="dest-{{ destination.slug }}" name="destination" value="{{ destination.slug }}"
{% if loop.first %}checked{% endif %} required>
<label for="dest-{{ destination.slug }}"><strong>{{ destination.city }}</strong><span>{{ destination.destination }}</span></label>
</div>
{% endfor %}
</div>
</div>
<!-- ── Calendar date picker ──────────────────────────────────────── -->
<div class="form-group-lg">
<label for="travel_date" class="field-label">
Travel date
</label>
<input type="date" id="travel_date" name="travel_date" required
min="{{ today }}" value="{{ today }}"
class="form-control">
<span class="field-label">Travel dates</span>
<div class="cal-wrap">
<div class="cal-nav">
<button type="button" id="cal-prev" class="cal-nav-btn" aria-label="Previous month">&#8249;</button>
<div id="cal-titles"></div>
<button type="button" id="cal-next" class="cal-nav-btn" aria-label="Next month">&#8250;</button>
</div>
<div id="cal-months"></div>
<div id="cal-hint" class="cal-hint"></div>
</div>
<!-- Hidden inputs submitted with the form -->
<input type="hidden" id="travel_date" name="travel_date">
<input type="hidden" id="return_date" name="">
</div>
<div class="form-group">
<label for="min_connection" class="field-label">
Minimum connection time (Paddington &rarr; St&nbsp;Pancras)
</label>
<select id="min_connection" name="min_connection" class="form-control">
{% for mins in valid_min_connections %}
<option value="{{ mins }}" {% if mins == default_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
<div class="form-row">
<div class="form-group">
<label for="min_connection" class="field-label">
Minimum connection time (Paddington &rarr; St&nbsp;Pancras)
</label>
<select id="min_connection" name="min_connection" class="form-control">
{% for mins in valid_min_connections %}
<option value="{{ mins }}" {% if mins == default_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="max_connection" class="field-label">
Maximum connection time (Paddington &rarr; St&nbsp;Pancras)
</label>
<select id="max_connection" name="max_connection" class="form-control">
{% for mins in valid_max_connections %}
<option value="{{ mins }}" {% if mins == default_max_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group-lg">
<label for="max_connection" class="field-label">
Maximum connection time (Paddington &rarr; St&nbsp;Pancras)
</label>
<select id="max_connection" name="max_connection" class="form-control">
{% for mins in valid_max_connections %}
<option value="{{ mins }}" {% if mins == default_max_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn-primary">
Search journeys
</button>
<button type="submit" class="btn-primary">Search journeys</button>
</form>
<script>
(function () {
'use strict';
const TODAY = new Date('{{ today }}T00:00:00');
/* ── state ───────────────────────────────────────────────────────── */
let viewYear = TODAY.getFullYear();
let viewMonth = TODAY.getMonth(); // 0-based
let outDate = new Date(TODAY); // default for single journeys
let retDate = null;
let hoverDate = null;
let isReturn = false;
let retPhase = false; // true while waiting for return-date click
/* ── helpers ─────────────────────────────────────────────────────── */
function toYMD(d) {
if (!d) return '';
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function dispDate(d) {
return d ? d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : '';
}
function sameDay(a, b) {
return a && b && a.toDateString() === b.toDateString();
}
function advMonth(y, m, delta) {
m += delta;
while (m < 0) { m += 12; y--; }
while (m > 11) { m -= 12; y++; }
return [y, m];
}
/* ── sync hidden inputs ──────────────────────────────────────────── */
function syncInputs() {
document.getElementById('travel_date').value = toYMD(outDate);
var ri = document.getElementById('return_date');
if (isReturn && retDate) {
ri.value = toYMD(retDate);
ri.name = 'return_date';
} else {
ri.value = '';
ri.name = '';
}
}
/* ── day click ───────────────────────────────────────────────────── */
function onDayClick(d) {
if (!isReturn) {
outDate = d;
} else if (!outDate || !retPhase) {
/* start fresh: set outbound, await return */
outDate = d;
retDate = null;
retPhase = true;
} else {
/* selecting return date */
if (sameDay(d, outDate)) {
/* tapped same day → reset */
outDate = null;
retDate = null;
retPhase = false;
} else if (d < outDate) {
/* earlier than outbound → new outbound, keep retPhase */
outDate = d;
} else {
retDate = d;
retPhase = false;
}
}
hoverDate = null;
syncInputs();
render();
}
/* ── render ──────────────────────────────────────────────────────── */
function render() {
var fmt = { month: 'long', year: 'numeric' };
var t0 = new Date(viewYear, viewMonth, 1).toLocaleDateString('en-GB', fmt);
var titleHtml = '<span>' + t0 + '</span>';
if (isReturn) {
var next = advMonth(viewYear, viewMonth, 1);
var t1 = new Date(next[0], next[1], 1).toLocaleDateString('en-GB', fmt);
titleHtml += '<span>' + t1 + '</span>';
}
document.getElementById('cal-titles').innerHTML = titleHtml;
document.getElementById('cal-prev').disabled =
(viewYear === TODAY.getFullYear() && viewMonth === TODAY.getMonth());
renderMonths();
renderHint();
}
function renderMonths() {
var cont = document.getElementById('cal-months');
cont.innerHTML = '';
var n = isReturn ? 2 : 1;
for (var i = 0; i < n; i++) {
var ym = advMonth(viewYear, viewMonth, i);
cont.appendChild(buildMonth(ym[0], ym[1]));
}
}
function buildMonth(year, month) {
/* effective range (includes hover preview) */
var rangeA = null, rangeB = null;
if (outDate && retDate) {
rangeA = outDate < retDate ? outDate : retDate;
rangeB = outDate < retDate ? retDate : outDate;
} else if (outDate && retPhase && hoverDate && hoverDate > outDate) {
rangeA = outDate;
rangeB = hoverDate;
}
var wrap = document.createElement('div');
wrap.className = 'cal-month';
var grid = document.createElement('div');
grid.className = 'cal-grid';
/* day-of-week headers, Mon first */
['Mo','Tu','We','Th','Fr','Sa','Su'].forEach(function (lbl) {
var el = document.createElement('div');
el.className = 'cal-dow';
el.textContent = lbl;
grid.appendChild(el);
});
/* offset: Mon=0 … Sun=6 */
var firstDay = new Date(year, month, 1);
var offset = firstDay.getDay() - 1;
if (offset < 0) offset = 6;
var lastDate = new Date(year, month + 1, 0).getDate();
/* leading empty cells */
for (var p = 0; p < offset; p++) {
grid.appendChild(Object.assign(document.createElement('div'), { className: 'cal-cell' }));
}
/* day cells */
for (var d = 1; d <= lastDate; d++) {
var date = new Date(year, month, d);
var past = date < TODAY;
var isTod = sameDay(date, TODAY);
var isOut = sameDay(date, outDate);
var isRet = sameDay(date, retDate);
var col = (d - 1 + offset) % 7; /* 0=Mon, 6=Sun */
var inRange = rangeA && rangeB && date > rangeA && date < rangeB;
var isRA = rangeA && sameDay(date, rangeA);
var isRB = rangeB && sameDay(date, rangeB);
var cell = document.createElement('div');
cell.className = 'cal-cell' + (inRange ? ' cal-in-range' : '');
/* range background: gradient at endpoints, solid between */
if (isRA && rangeB) {
cell.style.background = col < 6
? 'linear-gradient(to right,transparent 50%,#dbeafe 50%)'
: 'transparent'; /* Sunday: no gradient needed */
} else if (isRB && rangeA) {
cell.style.background = col > 0
? 'linear-gradient(to left,transparent 50%,#dbeafe 50%)'
: 'transparent'; /* Monday: no gradient needed */
} else if (inRange) {
cell.style.background = '#dbeafe';
}
var btn = document.createElement('button');
btn.type = 'button';
var cls = 'cal-btn';
if (past) cls += ' cal-past';
else if (isTod && !isOut && !isRet) cls += ' cal-today';
if (isOut || isRet) cls += ' cal-selected';
btn.className = cls;
btn.textContent = d;
btn.disabled = past;
btn.setAttribute('aria-label',
date.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }));
cell.setAttribute('data-date', toYMD(date));
cell.setAttribute('data-col', col);
if (!past) {
(function (dt) {
btn.addEventListener('click', function () { onDayClick(dt); });
btn.addEventListener('mouseenter', function () {
if (isReturn && retPhase && (!hoverDate || !sameDay(hoverDate, dt))) {
hoverDate = dt;
applyHoverStyles(outDate, dt);
}
});
})(new Date(date));
}
cell.appendChild(btn);
grid.appendChild(cell);
}
grid.addEventListener('mouseleave', function () {
if (hoverDate) {
hoverDate = null;
applyHoverStyles(
outDate && retDate ? (outDate < retDate ? outDate : retDate) : null,
outDate && retDate ? (outDate < retDate ? retDate : outDate) : null
);
}
});
wrap.appendChild(grid);
return wrap;
}
/* ── hover range: update cell styles in-place (no DOM rebuild) ───── */
function applyHoverStyles(rangeA, rangeB) {
document.querySelectorAll('.cal-cell[data-date]').forEach(function (cell) {
var d = new Date(cell.getAttribute('data-date') + 'T00:00:00');
var col = parseInt(cell.getAttribute('data-col'), 10);
var inRange = rangeA && rangeB && d > rangeA && d < rangeB;
var isRA = rangeA && rangeB && sameDay(d, rangeA);
var isRB = rangeA && rangeB && sameDay(d, rangeB);
cell.classList.toggle('cal-in-range', !!inRange);
if (isRA) {
cell.style.background = col < 6
? 'linear-gradient(to right,transparent 50%,#dbeafe 50%)' : '';
} else if (isRB) {
cell.style.background = col > 0
? 'linear-gradient(to left,transparent 50%,#dbeafe 50%)' : '';
} else if (inRange) {
cell.style.background = '#dbeafe';
} else {
cell.style.background = '';
}
});
}
function renderHint() {
var el = document.getElementById('cal-hint');
if (!el) return;
if (!isReturn) {
el.innerHTML = outDate
? 'Date: <strong>' + dispDate(outDate) + '</strong>'
: '<span class="cal-cta">Select a travel date</span>';
} else if (!outDate) {
el.innerHTML = '<span class="cal-cta">Select outbound date</span>';
} else if (retPhase) {
el.innerHTML = 'Outbound: <strong>' + dispDate(outDate) +
'</strong> &nbsp;&middot;&nbsp; <span class="cal-cta">Now select return date</span>';
} else {
el.innerHTML = 'Outbound: <strong>' + dispDate(outDate) +
'</strong> &nbsp;&middot;&nbsp; Return: <strong>' + dispDate(retDate) + '</strong>';
}
}
/* ── navigation ──────────────────────────────────────────────────── */
document.getElementById('cal-prev').addEventListener('click', function () {
var ym = advMonth(viewYear, viewMonth, -1);
viewYear = ym[0]; viewMonth = ym[1];
render();
});
document.getElementById('cal-next').addEventListener('click', function () {
var ym = advMonth(viewYear, viewMonth, 1);
viewYear = ym[0]; viewMonth = ym[1];
render();
});
/* ── journey type ────────────────────────────────────────────────── */
document.querySelectorAll('input[name="journey_type"]').forEach(function (r) {
r.addEventListener('change', function () {
var wasReturn = isReturn;
isReturn = this.value === 'return';
if (isReturn && !wasReturn) {
outDate = null;
retDate = null;
retPhase = false;
} else if (!isReturn) {
if (!outDate) outDate = new Date(TODAY);
retDate = null;
retPhase = false;
}
syncInputs();
render();
});
});
/* ── form submit ─────────────────────────────────────────────────── */
document.getElementById('search-form').addEventListener('submit', function (e) {
if (!outDate) {
e.preventDefault();
alert('Please select a travel date.');
return;
}
if (isReturn && !retDate) {
e.preventDefault();
alert('Please select a return date.');
return;
}
syncInputs();
});
/* ── init ────────────────────────────────────────────────────────── */
var initType = document.querySelector('input[name="journey_type"]:checked');
isReturn = initType && initType.value === 'return';
retPhase = isReturn && outDate !== null;
syncInputs();
render();
}());
</script>
</div>
{% endblock %}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}{% if journey_type == 'inbound' %}{{ destination }} to {{ departure_station_name }} via Eurostar{% elif journey_type == 'return' %}{{ departure_station_name }} to {{ destination }} return via Eurostar{% else %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endif %}{% endblock %}
{% block og_title %}{{ self.title()|trim }}{% endblock %}
{% block og_description %}Train options from {{ departure_station_name }} to {{ destination }} via Paddington, St Pancras, and Eurostar.{% endblock %}
{% block twitter_title %}{{ self.title()|trim }}{% endblock %}
{% block twitter_description %}Train options from {{ departure_station_name }} to {{ destination }} via Paddington, St Pancras, and Eurostar.{% endblock %}
{% block content %}
<p class="back-link">
<a href="{{ index_url }}">&larr; New search</a>
</p>
<div class="card" style="margin-bottom:1.5rem">
<h2>
{% if journey_type == 'inbound' %}
{{ destination }} &rarr; {{ departure_station_name }}
{% elif journey_type == 'return' %}
{{ departure_station_name }} &harr; {{ destination }}
{% else %}
{{ departure_station_name }} &rarr; {{ destination }}
{% endif %}
</h2>
<p class="card-meta">
{{ travel_date_display }}{% if return_date_display %} to {{ return_date_display }}{% endif %}
</p>
<div class="loading-panel" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<div>
<strong>Loading train times and fares</strong>
<p class="text-muted text-sm">Fetching National Rail, Eurostar, and fare data. Results will appear here as soon as they are ready.</p>
</div>
</div>
<noscript>
<p><a href="{{ full_results_url }}">Load results</a></p>
</noscript>
</div>
<script>
(function() {
var attempts = 0;
function runScripts(root) {
root.querySelectorAll('script').forEach(function(oldScript) {
var script = document.createElement('script');
for (var i = 0; i < oldScript.attributes.length; i++) {
var attr = oldScript.attributes[i];
script.setAttribute(attr.name, attr.value);
}
script.text = oldScript.text;
oldScript.parentNode.replaceChild(script, oldScript);
});
}
function showError() {
var panel = document.querySelector('.loading-panel');
if (panel) {
panel.innerHTML = '<div><strong>Could not load results</strong><p class="text-muted text-sm"><a href="{{ full_results_url }}">Try loading the full results page</a>.</p></div>';
}
}
function loadResults() {
if (!window.EventSource) {
window.location.href = {{ full_results_url|tojson }};
return;
}
attempts += 1;
var source = new EventSource({{ stream_url|tojson }});
source.onmessage = function(event) {
var msg;
try { msg = JSON.parse(event.data); } catch(e) { return; }
if (msg.type === 'shell') {
var doc = new DOMParser().parseFromString(msg.html, 'text/html');
document.title = doc.title;
var nextMain = doc.querySelector('main');
var currentMain = document.querySelector('main');
if (!nextMain || !currentMain) { source.close(); return; }
currentMain.innerHTML = nextMain.innerHTML;
runScripts(currentMain);
history.replaceState(null, '', window.location.href);
} else if (msg.type === 'section') {
var placeholder = document.getElementById('section-placeholder-' + msg.id);
if (placeholder) {
var tmp = document.createElement('div');
tmp.innerHTML = msg.html;
var card = tmp.firstElementChild;
if (card) placeholder.parentNode.replaceChild(card, placeholder);
}
if (typeof mergeSectionData === 'function') mergeSectionData(msg);
} else if (msg.type === 'done') {
if (typeof finaliseResults === 'function') finaliseResults(msg);
source.close();
} else if (msg.type === 'error') {
source.close();
showError();
}
};
source.onerror = function() {
source.close();
if (attempts < 3) {
window.setTimeout(loadResults, attempts * 2000);
} else {
showError();
}
};
}
loadResults();
})();
</script>
{% endblock %}

View file

@ -0,0 +1,172 @@
<div class="card" style="margin-bottom:1.5rem">
<h2>
{% if section.direction == 'inbound' %}
Return: {{ destination }} &rarr; {{ departure_station_name }}
{% else %}
Outbound: {{ departure_station_name }} &rarr; {{ destination }}
{% endif %}
</h2>
<p class="card-meta">{{ section.date_display }}</p>
{% if section.rows %}
<table class="results-table">
<thead>
<tr>
{% if section.direction == 'inbound' %}
<th class="flow-step">Eurostar<br><span class="text-xs font-normal text-muted">{{ destination }} &rarr; St Pancras</span></th>
<th class="col-transfer flow-step">Transfer<br><span class="text-xs font-normal text-muted">St Pancras &rarr; Paddington</span></th>
<th>National Rail<br><span class="text-xs font-normal text-muted">Paddington &rarr; {{ departure_station_name }}</span></th>
{% else %}
<th class="flow-step">National Rail<br><span class="text-xs font-normal text-muted">{{ departure_station_name }} &rarr; Paddington</span></th>
<th class="col-transfer flow-step">Transfer<br><span class="text-xs font-normal text-muted">Paddington &rarr; St Pancras</span></th>
<th>Eurostar<br><span class="text-xs font-normal text-muted">St Pancras &rarr; {{ destination }}</span></th>
{% endif %}
<th class="nowrap">Total<br><span class="text-xs font-normal text-muted">click row to select</span></th>
</tr>
</thead>
<tbody>
{% set trip_rows = section.rows | selectattr('row_type', 'equalto', 'trip') | list %}
{% if trip_rows %}
{% set best_mins = trip_rows | map(attribute='total_minutes') | min %}
{% set worst_mins = trip_rows | map(attribute='total_minutes') | max %}
{% endif %}
{% for row in section.rows %}
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
{% set row_class = 'row-fast' %}
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %}
{% set row_class = 'row-slow' %}
{% elif row.row_type == 'unreachable' %}
{% set row_class = 'row-unreachable' %}
{% elif loop.index is odd %}
{% set row_class = 'row-alt' %}
{% else %}
{% set row_class = '' %}
{% endif %}
<tr class="{{ row_class }}{% if row.row_type == 'trip' %} row-selectable{% endif %}"
data-row-key="{{ row.row_key }}"
{% if row.eurostar_price is not none %}data-es-std="{{ row.eurostar_price }}"{% endif %}
{% if row.row_type == 'trip' %}onclick="selectRow(this)"{% endif %}>
{% if row.row_type == 'trip' %}
{% if section.direction == 'inbound' %}
<td>
<span class="font-bold nowrap"><span class="font-normal text-muted" style="font-size:0.85em">(CET)</span> {{ row.depart_destination }} &rarr; {{ row.arrive_st_pancras }} <span class="font-normal text-muted" style="font-size:0.85em">(UK)</span></span>
<br><span class="text-xs text-muted">check in by {{ row.check_in_by }}</span>
{% if row.eurostar_duration or row.train_number %}
<br><span class="text-xs text-muted">
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
{%- if row.eurostar_duration and row.train_number %} &middot; {% endif %}
{%- if row.train_number %}{{ row.train_number }}{% endif %}
</span>
{% endif %}
<span class="fare-line es-standard"><span class="text-xs text-muted">Std</span>{% if row.eurostar_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
<span class="fare-line es-plus"><span class="text-xs text-muted">SP</span>{% if row.eurostar_plus_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
<span class="text-xs text-muted mobile-conn">then {{ row.connection_duration }} to station{% if row.connection_minutes < 45 %} {% endif %}</span>
</td>
<td class="col-transfer" style="color:#4a5568">
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 45 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
{% if row.circle_services %}
{% if row.circle_services | length > 1 %}
{% set c_early = row.circle_services[0] %}
{% set c = row.circle_services[1] %}
<br><span class="text-xs text-muted nowrap">Circle {{ c_early.depart }} &rarr; PAD {{ c_early.arrive_pad }} · £{{ "%.2f"|format(c_early.fare) }}</span>
<br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c.depart }} &rarr; PAD {{ c.arrive_pad }}</span>
{% else %}
{% set c = row.circle_services[0] %}
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} &rarr; PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }}</span>
{% endif %}
{% endif %}
</td>
<td>
<span class="font-bold nowrap">{{ row.depart_paddington }} &rarr; {{ row.arrive_uk_station }}</span>
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
{% if row.headcode or row.arrive_platform %}
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} &middot; {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
{% endif %}
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
<span class="fare-line nr-advance-std"></span>
<span class="fare-line nr-advance-1st"></span>
</td>
{% else %}
<td>
<span class="font-bold nowrap">{{ row.depart_bristol }} &rarr; {{ row.arrive_paddington }}</span>
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
{% if row.headcode or row.arrive_platform %}
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} &middot; {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
{% endif %}
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
<span class="fare-line nr-advance-std"></span>
<span class="fare-line nr-advance-1st"></span>
<span class="text-xs text-muted mobile-conn">then {{ row.connection_duration }} to Eurostar{% if row.connection_minutes < 80 %} {% endif %}</span>
</td>
<td class="col-transfer" style="color:#4a5568">
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
{% if row.circle_services %}
{% set c = row.circle_services[0] %}
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} &rarr; KX {{ c.arrive_kx }} · £{{ "%.2f"|format(c.fare) }}</span>
{% if row.circle_services | length > 1 %}
{% set c2 = row.circle_services[1] %}
<br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c2.depart }} &rarr; KX {{ c2.arrive_kx }} · £{{ "%.2f"|format(c2.fare) }}</span>
{% endif %}
{% endif %}
</td>
<td>
<span class="font-bold nowrap">{{ row.depart_st_pancras }} &rarr; {{ row.arrive_destination }} <span class="font-normal text-muted" style="font-size:0.85em">(CET)</span></span>
{% if row.eurostar_duration or row.train_number %}
<br><span class="text-xs text-muted">
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
{%- if row.eurostar_duration and row.train_number %} &middot; {% endif %}
{%- if row.train_number %}{{ row.train_number }}{% endif %}
</span>
{% endif %}
<span class="fare-line es-standard"><span class="text-xs text-muted">Std</span>{% if row.eurostar_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
<span class="fare-line es-plus"><span class="text-xs text-muted">SP</span>{% if row.eurostar_plus_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
</td>
{% endif %}
<td class="font-bold nowrap">
{% if row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
{% elif row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %}
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
{% else %}
<span class="text-blue">{{ row.total_duration }}</span>
{% endif %}
<br><span class="total-price"></span>
</td>
{% else %}
<td>
<span class="text-dimmed text-sm" title="No National Rail service from {{ departure_station_name }} arrives at Paddington in time for this Eurostar">Too early</span>
</td>
<td class="col-transfer text-dimmed">&mdash;</td>
<td>
{% if section.direction == 'inbound' %}
<span class="font-bold nowrap text-dimmed">{{ row.depart_destination }} &rarr; {{ row.arrive_st_pancras }}</span>
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
{% else %}
<span class="font-bold nowrap text-dimmed">{{ row.depart_st_pancras }} &rarr; {{ row.arrive_destination }}</span>
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
{% endif %}
<span class="fare-line es-standard"></span>
<span class="fare-line es-plus"></span>
</td>
<td class="text-dimmed nowrap"><span class="total-price"></span></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>No valid journeys found.</p>
<p>
{% if section.gwr_count == 0 and section.eurostar_count == 0 %}
Could not retrieve train data. Check your network connection or try again.
{% elif section.gwr_count == 0 %}
No National Rail trains found for this date.
{% elif section.eurostar_count == 0 %}
No Eurostar services found for {{ destination }} on this date.
{% else %}
No National Rail + Eurostar combination has a {{ section.min_connection }}-{{ section.max_connection }} minute connection.
{% endif %}
</p>
</div>
{% endif %}
</div>

View file

@ -0,0 +1,741 @@
{% extends "base.html" %}
{% block title %}{% if journey_type == 'inbound' %}{{ destination }} to {{ departure_station_name }} via Eurostar{% elif journey_type == 'return' %}{{ departure_station_name }} to {{ destination }} return via Eurostar{% else %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endif %}{% endblock %}
{% block og_title %}{{ self.title()|trim }}{% endblock %}
{% block og_description %}Train options from {{ departure_station_name }} to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
{% block twitter_title %}{{ self.title()|trim }}{% endblock %}
{% block twitter_description %}Train options from {{ departure_station_name }} to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
{% block content %}
<p class="back-link">
<a href="{{ url_for('index') }}">&larr; New search</a>
</p>
<div class="card" style="margin-bottom:1.5rem">
<h2>
{% if journey_type == 'inbound' %}
{{ destination }} &rarr; {{ departure_station_name }}
{% elif journey_type == 'return' %}
{{ departure_station_name }} &harr; {{ destination }}
{% else %}
{{ departure_station_name }} &rarr; {{ destination }}
{% endif %}
</h2>
{% if journey_type == 'return' %}
<div class="date-nav">
<span class="date-nav-label">Outbound:</span>
<a href="{{ prev_outbound_url }}" class="btn-nav">&larr; Prev</a>
<strong>{{ travel_date_display }}</strong>
<a href="{{ next_outbound_url }}" class="btn-nav">Next &rarr;</a>
</div>
<div class="date-nav">
<span class="date-nav-label">Return:</span>
<a href="{{ prev_return_url }}" class="btn-nav">&larr; Prev</a>
<strong>{{ return_date_display }}</strong>
<a href="{{ next_return_url }}" class="btn-nav">Next &rarr;</a>
</div>
{% else %}
<div class="date-nav">
<a href="{{ prev_results_url }}"
class="btn-nav">&larr; Prev</a>
<strong>{{ travel_date_display }}</strong>
<a href="{{ next_results_url }}"
class="btn-nav">Next &rarr;</a>
</div>
{% endif %}
<div class="switcher-section">
<div class="section-label">Switch destination for {{ travel_date_display }}</div>
<div class="chip-row">
{% for destination_slug, destination_name, destination_url in destination_links %}
{% if destination_slug == slug %}
<span class="chip-current">{{ destination_name }}</span>
{% else %}
<a
class="chip-link"
href="{{ destination_url }}"
>{{ destination_name }}</a>
{% endif %}
{% endfor %}
</div>
</div>
<div class="filter-row">
<div>
<label for="min_conn_select" class="filter-label">Min connection:</label>
<select id="min_conn_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_min_connections %}
<option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
<div>
<label for="max_conn_select" class="filter-label">Max connection:</label>
<select id="max_conn_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_max_connections %}
<option value="{{ mins }}" {% if mins == max_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
</div>
{% if journey_type == 'return' %}
{% for section in sections %}
<div class="filter-row" style="margin-top:0.5rem">
<span class="filter-label" style="min-width:5.5rem">{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}:</span>
{% if section.direction == 'inbound' %}
<div>
<label for="min_conn_in_select" class="filter-label">Min connection:</label>
<select id="min_conn_in_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_inbound_return_min_connections %}
<option value="{{ mins }}" {% if mins == inbound_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<span class="filter-label">NR:</span>
<div class="btn-group">
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'walkon' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="walkon" onclick="setNrClass('walkon', '{{ section.id }}')">Walk-on Std</button>
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'advance_std' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="advance_std" onclick="setNrClass('advance_std', '{{ section.id }}')">Advance Std</button>
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'advance_1st' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="advance_1st" onclick="setNrClass('advance_1st', '{{ section.id }}')">Advance 1st</button>
</div>
</div>
<div>
<span class="filter-label">Eurostar:</span>
<div class="btn-group">
<button type="button" class="btn-group-option {% if es_classes[section.id] == 'standard' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="standard" onclick="setEsClass('standard', '{{ section.id }}')">Standard</button>
<button type="button" class="btn-group-option {% if es_classes[section.id] == 'plus' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="plus" onclick="setEsClass('plus', '{{ section.id }}')">Standard Premier</button>
</div>
</div>
</div>
{% endfor %}
<div style="font-size:0.82rem;color:#718096;margin-top:0.4rem">
<span id="walkon-loading"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading fares</span>
<span id="advance-loading" style="display:none"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading advance fares</span>
</div>
{% else %}
{% set section = sections[0] %}
<div class="filter-row" style="margin-top:0.5rem">
<div>
<span class="filter-label">NR ticket:</span>
<span id="nr-type-select" style="display:none"></span>
<span style="display:none">Load advance prices</span>
<div class="btn-group">
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'walkon' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="walkon" onclick="setNrClass('walkon', '{{ section.id }}')">Walk-on Std</button>
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'advance_std' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="advance_std" onclick="setNrClass('advance_std', '{{ section.id }}')">Advance Std</button>
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'advance_1st' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="advance_1st" onclick="setNrClass('advance_1st', '{{ section.id }}')">Advance 1st</button>
</div>
<span id="walkon-loading"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading fares</span>
<span id="advance-loading" style="display:none"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading advance fares</span>
</div>
<div>
<span class="filter-label">Eurostar:</span>
<span id="es-type-select" style="display:none"></span>
<div class="btn-group">
<button type="button" class="btn-group-option {% if es_classes[section.id] == 'standard' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="standard" onclick="setEsClass('standard', '{{ section.id }}')">Standard</button>
<button type="button" class="btn-group-option {% if es_classes[section.id] == 'plus' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="plus" onclick="setEsClass('plus', '{{ section.id }}')">Standard Premier</button>
</div>
</div>
</div>
{% endif %}
<script>
const RESULTS_BASE = '{{ results_base_url }}';
const DEFAULT_MIN_CONN = {{ default_min_connection }};
const DEFAULT_MAX_CONN = {{ default_max_connection }};
const DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }};
let TRIP_FARES = {};
let ADVANCE_FARES = null;
let WALKON_CACHED_FARES = {};
let WALKON_API_URLS = {};
let ADVANCE_API_URLS = {};
let ADVANCE_STREAM_URLS = {};
let TIMETABLE_REFRESH_URL = null;
let HAS_PROVISIONAL_TIMETABLE = false;
let eurostarRefreshPending = false;
let cachedAdvanceFares = null;
let currentNrClasses = {{ nr_classes_json | safe }};
let currentEsClasses = {{ es_classes_json | safe }};
const SECTION_DIRECTIONS = {{ section_directions_json | safe }};
let advanceLoadingSections = {};
let walkonLoadingSections = {};
let selectedRowKeys = {};
function updateAdvanceLoadingStatus() {
var loading = Object.keys(advanceLoadingSections).some(function(sectionId) {
return advanceLoadingSections[sectionId];
});
var el = document.getElementById('advance-loading');
if (el) el.style.display = loading ? 'inline-flex' : 'none';
}
function buildUrl() {
var min = parseInt(document.getElementById('min_conn_select').value);
var max = parseInt(document.getElementById('max_conn_select').value);
var params = [];
if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min);
if (max !== DEFAULT_MAX_CONN) params.push('max_connection=' + max);
var minInEl = document.getElementById('min_conn_in_select');
if (minInEl) {
var minIn = parseInt(minInEl.value);
if (minIn !== DEFAULT_MIN_CONN_IN) params.push('min_connection_in=' + minIn);
}
var sectionIds = Object.keys(currentNrClasses);
if (sectionIds.length === 1) {
var nrCls = currentNrClasses[sectionIds[0]];
var esCls = currentEsClasses[sectionIds[0]];
if (nrCls !== 'walkon') params.push('nr_class=' + nrCls);
if (esCls !== 'standard') params.push('es_class=' + esCls);
} else {
sectionIds.forEach(function(sid) {
var dir = SECTION_DIRECTIONS[sid];
var suffix = dir === 'outbound' ? '_out' : '_in';
var nrC = currentNrClasses[sid];
var esC = currentEsClasses[sid];
if (nrC !== 'walkon') params.push('nr_class' + suffix + '=' + nrC);
if (esC !== 'standard') params.push('es_class' + suffix + '=' + esC);
});
}
for (var _sid in selectedRowKeys) {
var _sel = selectedRowKeys[_sid];
if (!_sel || !SECTION_DIRECTIONS[_sid]) continue;
var _pname = SECTION_DIRECTIONS[_sid] === 'outbound' ? 'out' : 'ret';
params.push(_pname + '=' + encodeURIComponent(_sel.slice(_sid.length + 1)));
}
return params.length ? RESULTS_BASE + '?' + params.join('&') : RESULTS_BASE;
}
function applyConnectionFilter() {
window.location = buildUrl();
}
function setNrClass(cls, sectionId) {
currentNrClasses[sectionId] = cls;
document.querySelectorAll('.btn-group-option[data-section="' + sectionId + '"][data-nr-class]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-nr-class') === cls);
});
history.replaceState(null, '', buildUrl());
if (cls === 'advance_std' || cls === 'advance_1st') loadAdvanceFaresForSectionStreaming(sectionId);
updateDisplay();
}
function setEsClass(cls, sectionId) {
currentEsClasses[sectionId] = cls;
document.querySelectorAll('.btn-group-option[data-section="' + sectionId + '"][data-es-class]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-es-class') === cls);
});
history.replaceState(null, '', buildUrl());
updateDisplay();
}
function fmtPrice(p) {
return '£' + p.toFixed(2);
}
function fareHtml(fare) {
return '<span class="text-sm font-bold">' + fmtPrice(fare.price) + '</span>'
+ (fare.ticket ? ' <span class="text-xs text-muted">' + fare.ticket + '</span>' : '')
+ (fare.seats != null ? ' <span class="text-xs text-muted fare-seats">' + fare.seats + ' at this price</span>' : '');
}
function mergeAdvanceFares(sectionId, fares) {
if (!ADVANCE_FARES) ADVANCE_FARES = {};
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
for (var time in fares) {
if (!ADVANCE_FARES[sectionId][time]) {
ADVANCE_FARES[sectionId][time] = {advance_std: null, advance_1st: null};
}
if (fares[time].advance_std) ADVANCE_FARES[sectionId][time].advance_std = fares[time].advance_std;
if (fares[time].advance_1st) ADVANCE_FARES[sectionId][time].advance_1st = fares[time].advance_1st;
}
}
function mergeWalkonFares(sectionId, fares) {
for (var key in TRIP_FARES) {
var row = TRIP_FARES[key];
if (row.section !== sectionId || !row.advance_key || !fares[row.advance_key]) continue;
var fare = fares[row.advance_key];
row.walkon = {price: fare.price, ticket: fare.ticket || ''};
}
}
function mergeEurostarPrices(prices) {
for (var key in prices) {
for (var rowKey in TRIP_FARES) {
var row = TRIP_FARES[rowKey];
if (rowKey !== key && row.eurostar_key !== key) continue;
if (prices[key].es_standard) row.es_standard = prices[key].es_standard;
if (prices[key].es_standard_status !== undefined) row.es_standard_status = prices[key].es_standard_status;
if (prices[key].es_plus) row.es_plus = prices[key].es_plus;
if (prices[key].es_plus_status !== undefined) row.es_plus_status = prices[key].es_plus_status;
}
}
}
function eurostarMissingText(row, esClass) {
var status = esClass === 'standard' ? row.es_standard_status : row.es_plus_status;
if (status === 'sold_out') return 'Eurostar sold out';
if (status === 'price_not_returned') return 'No Eurostar price returned';
return 'No Eurostar price returned';
}
function eurostarMissingTitle(row, esClass) {
var status = esClass === 'standard' ? row.es_standard_status : row.es_plus_status;
if (status === 'sold_out') return 'Eurostar returned 0 seats at this price for the selected class.';
if (status === 'price_not_returned') return 'Eurostar returned this service without a price for the selected class.';
return 'Eurostar did not return a price for the selected class. Check eurostar.com.';
}
function eurostarMissingFareHtml(row, esClass) {
var status = esClass === 'standard' ? row.es_standard_status : row.es_plus_status;
if (eurostarRefreshPending && !status) return '<span class="text-sm text-muted">checking</span>';
if (status === 'sold_out') return '<span class="text-sm text-muted">sold out</span>';
return '<span class="text-sm text-muted"></span>';
}
function sectionNeedsAdvance(sectionId) {
var nrClass = currentNrClasses[sectionId] || 'walkon';
for (var key in TRIP_FARES) {
var row = TRIP_FARES[key];
if (row.section !== sectionId || !row.advance_key) continue;
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[sectionId];
var advanceFares = sectionFares && sectionFares[row.advance_key];
if (!advanceFares) return true;
if (nrClass === 'advance_std' && !advanceFares.advance_std) return true;
if (nrClass === 'advance_1st' && !advanceFares.advance_1st) return true;
}
return false;
}
function loadAdvanceFaresForSection(sectionId) {
if (advanceLoadingSections[sectionId] || !ADVANCE_API_URLS[sectionId]) return;
advanceLoadingSections[sectionId] = true;
updateAdvanceLoadingStatus();
if (!ADVANCE_FARES) ADVANCE_FARES = {};
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
fetch(ADVANCE_API_URLS[sectionId])
.then(function(response) {
if (!response.ok) throw new Error('advance fare request failed');
return response.json();
})
.then(function(fares) {
mergeAdvanceFares(sectionId, fares);
})
.catch(function() {})
.finally(function() {
advanceLoadingSections[sectionId] = false;
updateAdvanceLoadingStatus();
updateDisplay();
});
}
function loadAdvanceFaresForSectionStreaming(sectionId) {
if (advanceLoadingSections[sectionId] || !ADVANCE_STREAM_URLS[sectionId]) return;
advanceLoadingSections[sectionId] = true;
updateAdvanceLoadingStatus();
if (!ADVANCE_FARES) ADVANCE_FARES = {};
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
var hadMessage = false;
var source = new EventSource(ADVANCE_STREAM_URLS[sectionId]);
source.onmessage = function(event) {
hadMessage = true;
var msg = JSON.parse(event.data);
if (msg.type === 'fares') {
mergeAdvanceFares(sectionId, msg.fares);
updateDisplay();
}
if (msg.type === 'done' || msg.type === 'error') {
advanceLoadingSections[sectionId] = false;
source.close();
updateAdvanceLoadingStatus();
updateDisplay();
}
};
source.onerror = function() {
advanceLoadingSections[sectionId] = false;
source.close();
updateAdvanceLoadingStatus();
if (!hadMessage && ADVANCE_API_URLS[sectionId]) {
loadAdvanceFaresForSection(sectionId);
} else {
updateDisplay();
}
};
}
function loadMissingAdvanceFares() {
for (var sectionId in ADVANCE_STREAM_URLS) {
var nrClass = currentNrClasses[sectionId] || 'walkon';
if ((nrClass === 'advance_std' || nrClass === 'advance_1st') && sectionNeedsAdvance(sectionId)) {
loadAdvanceFaresForSectionStreaming(sectionId);
}
}
}
function currentNrFare(row) {
var nrClass = currentNrClasses[row.section] || 'walkon';
if (nrClass === 'walkon') return row.walkon;
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[row.section];
var advFares = sectionFares && row.advance_key ? sectionFares[row.advance_key] : null;
if (!advFares) return row.walkon;
return (nrClass === 'advance_std' ? advFares.advance_std : advFares.advance_1st) || row.walkon;
}
function updateDisplay() {
var totals = {};
document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
var key = tr.getAttribute('data-row-key');
var row = TRIP_FARES[key];
if (!row) return;
var nrFare = currentNrFare(row);
var esClass = currentEsClasses[row.section] || 'standard';
var esFare = esClass === 'standard' ? row.es_standard : row.es_plus;
if (nrFare && esFare) totals[key] = nrFare.price + esFare.price + (row.circle_fare || 0);
});
var totalValues = Object.values(totals);
var minTotal = totalValues.length > 1 ? Math.min.apply(null, totalValues) : null;
var maxTotal = totalValues.length > 1 ? Math.max.apply(null, totalValues) : null;
document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
var key = tr.getAttribute('data-row-key');
var row = TRIP_FARES[key];
if (!row) return;
var nrClass = currentNrClasses[row.section] || 'walkon';
var esClass = currentEsClasses[row.section] || 'standard';
var nrFare = currentNrFare(row);
var walkonEl = tr.querySelector('.nr-walkon');
var advStdEl = tr.querySelector('.nr-advance-std');
var adv1stEl = tr.querySelector('.nr-advance-1st');
if (walkonEl) {
walkonEl.innerHTML = row.walkon ? fareHtml(row.walkon) : '<span class="text-sm text-muted"></span>';
walkonEl.classList.toggle('fare-inactive', nrClass !== 'walkon');
}
if (advStdEl || adv1stEl) {
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[row.section];
var advFares = sectionFares && row.advance_key ? sectionFares[row.advance_key] : null;
if (advStdEl) {
advStdEl.innerHTML = advFares && advFares.advance_std ? fareHtml(advFares.advance_std) : '';
advStdEl.classList.toggle('fare-inactive', nrClass !== 'advance_std');
}
if (adv1stEl) {
adv1stEl.innerHTML = advFares && advFares.advance_1st ? fareHtml(advFares.advance_1st) : '';
adv1stEl.classList.toggle('fare-inactive', nrClass !== 'advance_1st');
}
}
var esStdEl = tr.querySelector('.es-standard');
var esPlusEl = tr.querySelector('.es-plus');
if (esStdEl) {
esStdEl.innerHTML = '<span class="text-xs text-muted">Std</span> ' + (row.es_standard ? fareHtml(row.es_standard) : eurostarMissingFareHtml(row, 'standard'));
esStdEl.classList.toggle('fare-inactive', esClass !== 'standard');
}
if (esPlusEl) {
esPlusEl.innerHTML = '<span class="text-xs text-muted">SP</span> ' + (row.es_plus ? fareHtml(row.es_plus) : eurostarMissingFareHtml(row, 'plus'));
esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus');
}
var totalSpan = tr.querySelector('.total-price');
if (totalSpan) {
if (key.indexOf(':unreachable:') !== -1) {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="No National Rail service from {{ departure_station_name }} arrives at Paddington in time for this Eurostar">No rail connection</span>';
} else if (key in totals) {
var total = totals[key];
var html = '<span class="text-sm text-green" style="font-weight:700">' + fmtPrice(total);
if (minTotal !== null && maxTotal !== null) {
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">🪙</span>';
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">💸</span>';
}
html += '</span>';
totalSpan.innerHTML = html;
} else if (!nrFare && walkonLoadingSections[row.section]) {
totalSpan.innerHTML = '';
} else if (!nrFare) {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="National Rail fare data is not available for this service">NR fare not available</span>';
} else if (eurostarRefreshPending) {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="Checking exact Eurostar data for this service">Checking Eurostar price</span>';
} else {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="' + eurostarMissingTitle(row, esClass) + ' Check eurostar.com.">' + eurostarMissingText(row, esClass) + '</span>';
}
}
});
updateSelectionBar();
}
function loadWalkonFares() {
var urls = WALKON_API_URLS;
var ids = Object.keys(urls);
if (!ids.length) return;
/* outbound first, then inbound */
ids.sort(function(a, b) {
return (SECTION_DIRECTIONS[a] === 'outbound' ? 0 : 1) -
(SECTION_DIRECTIONS[b] === 'outbound' ? 0 : 1);
});
ids.forEach(function(sid) { walkonLoadingSections[sid] = true; });
var pending = ids.length;
function done(id) {
walkonLoadingSections[id] = false;
if (--pending === 0) {
var el = document.getElementById('walkon-loading');
if (el) el.style.display = 'none';
}
}
/* sequential: each fetch starts only after the previous one finishes */
ids.reduce(function(chain, id) {
return chain.then(function() {
return fetch(urls[id])
.then(function(r) { return r.json(); })
.then(function(fares) { mergeWalkonFares(id, fares); done(id); updateDisplay(); })
.catch(function() { done(id); updateDisplay(); });
});
}, Promise.resolve());
}
function initSelectionFromUrl() {
var params = new URLSearchParams(window.location.search);
for (var sid in SECTION_DIRECTIONS) {
var dir = SECTION_DIRECTIONS[sid];
var val = params.get(dir === 'outbound' ? 'out' : 'ret');
if (val) {
var rowKey = sid + ':' + val;
if (TRIP_FARES[rowKey]) selectedRowKeys[sid] = rowKey;
}
}
}
function selectRow(tr) {
var key = tr.getAttribute('data-row-key');
if (!key || key.indexOf(':unreachable:') !== -1) return;
var row = TRIP_FARES[key];
if (!row) return;
selectedRowKeys[row.section] = (selectedRowKeys[row.section] === key) ? null : key;
updateRowHighlights();
updateSelectionBar();
history.replaceState(null, '', buildUrl());
}
function clearSelection() {
selectedRowKeys = {};
updateRowHighlights();
updateSelectionBar();
history.replaceState(null, '', buildUrl());
}
function updateRowHighlights() {
document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
var key = tr.getAttribute('data-row-key');
var row = TRIP_FARES[key];
if (!row) return;
tr.classList.toggle('row-selected', selectedRowKeys[row.section] === key);
});
}
function updateSelectionBar() {
var bar = document.getElementById('selection-bar');
if (!bar) return;
var allSids = Object.keys(SECTION_DIRECTIONS);
var activeSids = allSids.filter(function(sid) { return selectedRowKeys[sid]; });
if (activeSids.length === 0) { bar.style.display = 'none'; return; }
bar.style.display = 'block';
var totalNr = 0, totalEs = 0, totalCircle = 0, allPrices = true;
var parts = [];
activeSids.forEach(function(sid) {
var rowKey = selectedRowKeys[sid];
var row = TRIP_FARES[rowKey];
if (!row) return;
var nrFare = currentNrFare(row);
var esClass = currentEsClasses[sid] || 'standard';
var esFare = esClass === 'standard' ? row.es_standard : row.es_plus;
if (nrFare) totalNr += nrFare.price; else allPrices = false;
if (esFare) totalEs += esFare.price; else allPrices = false;
totalCircle += row.circle_fare || 0;
var kp = rowKey.split(':');
var depTime = SECTION_DIRECTIONS[sid] === 'outbound'
? kp[1] + ':' + kp[2]
: kp[3] + ':' + kp[4];
parts.push((SECTION_DIRECTIONS[sid] === 'outbound' ? 'Out ' : 'Ret ') + depTime);
});
var descEl = document.getElementById('sel-desc');
if (descEl) descEl.textContent = parts.join(' · ');
var hintEl = document.getElementById('sel-hint');
if (hintEl) {
if (allSids.length > 1 && activeSids.length < allSids.length) {
var missingDir = SECTION_DIRECTIONS[allSids.filter(function(s) { return !selectedRowKeys[s]; })[0]];
hintEl.textContent = 'Select a ' + (missingDir === 'outbound' ? 'outbound' : 'return') + ' journey to see combined total';
hintEl.style.display = '';
} else {
hintEl.style.display = 'none';
}
}
var grandTotal = totalNr + totalEs + totalCircle;
var nrEl = document.getElementById('sel-nr');
var esEl = document.getElementById('sel-es');
var grandEl = document.getElementById('sel-grand');
if (nrEl) nrEl.innerHTML = 'NR&nbsp;<strong>' + (allPrices ? fmtPrice(totalNr) : '') + '</strong>';
if (esEl) esEl.innerHTML = 'Eurostar&nbsp;<strong>' + (allPrices ? fmtPrice(totalEs) : '') + '</strong>';
if (grandEl) {
var label = (activeSids.length === allSids.length && allSids.length > 1) ? 'Grand total' : 'Total';
var priceHtml = allPrices
? '<strong style="font-size:1.05rem;color:#276749">' + fmtPrice(grandTotal) + '</strong>'
: '<strong></strong>';
grandEl.innerHTML = label + '&nbsp;' + priceHtml;
}
}
function initialiseResultsPage() {
initSelectionFromUrl();
var needsAdvance = Object.keys(currentNrClasses).some(function(sid) {
var c = currentNrClasses[sid];
return c === 'advance_std' || c === 'advance_1st';
});
if (needsAdvance) loadMissingAdvanceFares();
/* Pre-populate walk-on fares from weekday cache so prices show immediately */
var hasPreloaded = false;
for (var sid in WALKON_CACHED_FARES) {
if (WALKON_CACHED_FARES[sid]) { mergeWalkonFares(sid, WALKON_CACHED_FARES[sid]); hasPreloaded = true; }
}
updateDisplay();
updateRowHighlights();
if (hasPreloaded) {
var loadingEl = document.getElementById('walkon-loading');
if (loadingEl) loadingEl.innerHTML = '<span class="spinner spinner-inline" aria-hidden="true"></span>Verifying fares';
}
loadWalkonFares();
startTimetableRefresh();
}
function runInsertedScripts(root) {
root.querySelectorAll('script').forEach(function(oldScript) {
var script = document.createElement('script');
for (var i = 0; i < oldScript.attributes.length; i++) {
var attr = oldScript.attributes[i];
script.setAttribute(attr.name, attr.value);
}
script.text = oldScript.text;
oldScript.parentNode.replaceChild(script, oldScript);
});
}
function fullResultsUrl() {
var url = new URL(window.location.href);
url.searchParams.set('render', 'full');
return url.toString();
}
function refreshFullResults() {
fetch(fullResultsUrl(), {headers: {'X-Requested-With': 'fetch'}})
.then(function(response) {
if (!response.ok) throw new Error('Could not refresh results');
return response.text();
})
.then(function(html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var nextMain = doc.querySelector('main');
var currentMain = document.querySelector('main');
if (!nextMain || !currentMain) throw new Error('Results page was incomplete');
document.title = doc.title;
currentMain.innerHTML = nextMain.innerHTML;
runInsertedScripts(currentMain);
})
.catch(function() {});
}
function startTimetableRefresh() {
if (!HAS_PROVISIONAL_TIMETABLE || !TIMETABLE_REFRESH_URL || !window.EventSource) return;
eurostarRefreshPending = true;
updateDisplay();
var source = new EventSource(TIMETABLE_REFRESH_URL);
source.onmessage = function(event) {
var msg = JSON.parse(event.data);
if (msg.type === 'reload') {
source.close();
refreshFullResults();
} else if (msg.type === 'eurostar_prices') {
mergeEurostarPrices(msg.prices);
updateDisplay();
} else if (msg.type === 'walkon_fares') {
mergeWalkonFares(msg.section, msg.fares);
updateDisplay();
} else if (msg.type === 'done' || msg.type === 'error') {
eurostarRefreshPending = false;
source.close();
updateDisplay();
}
};
source.onerror = function() {
eurostarRefreshPending = false;
source.close();
updateDisplay();
};
}
function mergeSectionData(msg) {
if (msg.trip_fares) Object.assign(TRIP_FARES, msg.trip_fares);
var sid = msg.id;
if (msg.advance_fares !== undefined) {
if (!ADVANCE_FARES) ADVANCE_FARES = {};
ADVANCE_FARES[sid] = msg.advance_fares;
}
if (msg.walkon_cached_fares !== undefined) WALKON_CACHED_FARES[sid] = msg.walkon_cached_fares;
if (msg.walkon_api_url) WALKON_API_URLS[sid] = msg.walkon_api_url;
if (msg.advance_api_url) ADVANCE_API_URLS[sid] = msg.advance_api_url;
if (msg.advance_stream_url) ADVANCE_STREAM_URLS[sid] = msg.advance_stream_url;
}
function finaliseResults(msg) {
TIMETABLE_REFRESH_URL = msg.timetable_refresh_url || null;
HAS_PROVISIONAL_TIMETABLE = msg.provisional_timetable || false;
eurostarRefreshPending = HAS_PROVISIONAL_TIMETABLE && !!TIMETABLE_REFRESH_URL && !!window.EventSource;
var summaryEl = document.getElementById('results-summary');
if (summaryEl && msg.summary_html) summaryEl.innerHTML = msg.summary_html;
initialiseResultsPage();
}
</script>
<p id="results-summary" class="card-meta">
<span class="spinner spinner-inline" aria-hidden="true"></span>
</p>
<div id="results-alerts"></div>
</div>
{% for section in sections %}
<div id="section-placeholder-{{ section.id }}" class="card" style="margin-bottom:1.5rem">
<div class="loading-panel" role="status">
<span class="spinner" aria-hidden="true"></span>
<div><strong>Loading {{ 'return' if section.direction == 'inbound' else 'outbound' }} results&hellip;</strong></div>
</div>
</div>
{% endfor %}
<p class="footnote">
Connection windows:
{% for section in sections %}
{% if section.direction == 'inbound' %}return{% else %}outbound{% endif %}
{{ section.min_connection }}&ndash;{{ section.max_connection }}&nbsp;min{% if not loop.last %}; {% endif %}
{% endfor %}.
National Rail prices from <a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
Eurostar prices are for 1 adult in GBP; return searches use Eurostar return-search prices.
Always check <a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
&nbsp;&middot;&nbsp;
<a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} on RTT</a>
&nbsp;&middot;&nbsp;
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington on RTT</a>
</p>
<div id="selection-bar">
<div class="sel-bar-inner">
<div>
<span id="sel-desc" style="color:#2d3748"></span>
<span id="sel-hint" style="display:none; margin-left:1rem; color:#a0aec0; font-size:0.8rem"></span>
</div>
<div class="sel-totals">
<span id="sel-nr" class="text-muted"></span>
<span id="sel-es" class="text-muted"></span>
<span id="sel-grand"></span>
<button class="sel-clear" onclick="clearSelection()">Clear</button>
</div>
</div>
</div>
{% endblock %}

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,53 @@
import os
import time
from pathlib import Path
from typing import Any
import pytest
from cache import get_cached, set_cached
@pytest.fixture
def tmp_cache(tmp_path, monkeypatch):
def tmp_cache(tmp_path: Path, monkeypatch: Any) -> Path:
import cache as cache_module
monkeypatch.setattr(cache_module, 'CACHE_DIR', str(tmp_path))
monkeypatch.setattr(cache_module, "CACHE_DIR", str(tmp_path))
return tmp_path
def test_get_cached_returns_none_for_missing_key(tmp_cache):
assert get_cached('no_such_key') is None
def test_get_cached_returns_none_for_missing_key(tmp_cache: Path) -> None:
assert get_cached("no_such_key") is None
def test_set_and_get_cached_roundtrip(tmp_cache):
set_cached('my_key', {'a': 1})
assert get_cached('my_key') == {'a': 1}
def test_set_and_get_cached_roundtrip(tmp_cache: Path) -> None:
set_cached("my_key", {"a": 1})
assert get_cached("my_key") == {"a": 1}
def test_get_cached_no_ttl_never_expires(tmp_cache):
set_cached('k', [1, 2, 3])
def test_get_cached_no_ttl_never_expires(tmp_cache: Path) -> None:
set_cached("k", [1, 2, 3])
# Backdate the file by 2 days
path = tmp_cache / 'k.json'
path = tmp_cache / "k.json"
old = time.time() - 2 * 86400
os.utime(path, (old, old))
assert get_cached('k') == [1, 2, 3]
assert get_cached("k") == [1, 2, 3]
def test_get_cached_within_ttl(tmp_cache):
set_cached('k', 'fresh')
assert get_cached('k', ttl=3600) == 'fresh'
def test_get_cached_within_ttl(tmp_cache: Path) -> None:
set_cached("k", "fresh")
assert get_cached("k", ttl=3600) == "fresh"
def test_get_cached_expired_returns_none(tmp_cache):
set_cached('k', 'stale')
path = tmp_cache / 'k.json'
def test_get_cached_expired_returns_none(tmp_cache: Path) -> None:
set_cached("k", "stale")
path = tmp_cache / "k.json"
old = time.time() - 25 * 3600 # 25 hours ago
os.utime(path, (old, old))
assert get_cached('k', ttl=24 * 3600) is None
assert get_cached("k", ttl=24 * 3600) is None
def test_get_cached_invalid_json_returns_none(tmp_cache: Path) -> None:
path = tmp_cache / "broken.json"
path.write_text('{"not": "finished"')
assert get_cached("broken") is None

View file

@ -1,30 +1,47 @@
from typing import Any
import pytest
from scraper.eurostar import _parse_graphql, search_url
from scraper.eurostar import _parse_graphql, _parse_graphql_leg, search_url
def _gql_response(journeys: list) -> dict:
return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}}
def _gql_response(journeys: list[dict[str, Any]]) -> dict[str, Any]:
return {"data": {"journeySearch": {"outbound": {"journeys": journeys}}}}
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 [],
}]
def _journey(
departs: str,
arrives: str,
price: float | None = None,
seats: int | None = None,
service_name: str = "",
carrier: str = "ES",
plus_price: float | None = None,
plus_seats: int | None = None,
) -> dict[str, Any]:
fares: list[dict[str, Any]] = [
{
"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': [],
})
fares.append(
{
"classOfService": {"code": "PLUS"},
"prices": {"displayPrice": plus_price},
"seats": plus_seats,
"legs": [],
}
)
return {
'timing': {'departureTime': departs, 'arrivalTime': arrives},
'fares': fares,
"timing": {"departureTime": departs, "arrivalTime": arrives},
"fares": fares,
}
@ -32,91 +49,149 @@ def _journey(departs: str, arrives: str, price=None, seats=None, service_name=''
# _parse_graphql
# ---------------------------------------------------------------------------
def test_parse_graphql_single_journey():
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014')])
services = _parse_graphql(data, 'Paris Gare du Nord')
def test_parse_graphql_single_journey() -> None:
data = _gql_response(
[_journey("09:31", "12:55", price=156, seats=37, service_name="9014")]
)
services = _parse_graphql(data, "Paris Gare du Nord")
assert len(services) == 1
s = services[0]
assert s['depart_st_pancras'] == '09:31'
assert s['arrive_destination'] == '12:55'
assert s['destination'] == 'Paris Gare du Nord'
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
assert s["depart_st_pancras"] == "09:31"
assert s["arrive_destination"] == "12:55"
assert s["destination"] == "Paris Gare du Nord"
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')
def test_parse_graphql_standard_premier_price() -> None:
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
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_plus_price_none_when_not_returned() -> None:
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():
data = _gql_response([_journey('09:01', '14:20', price=192.5, seats=25, service_name='9116')])
services = _parse_graphql(data, 'Amsterdam Centraal')
assert services[0]['price'] == 192.5
def test_parse_graphql_half_pound_price() -> None:
data = _gql_response(
[_journey("09:01", "14:20", price=192.5, seats=25, service_name="9116")]
)
services = _parse_graphql(data, "Amsterdam Centraal")
assert services[0]["price"] == 192.5
def test_parse_graphql_null_price():
data = _gql_response([_journey('06:16', '11:09', price=None, seats=0)])
services = _parse_graphql(data, 'Amsterdam Centraal')
assert services[0]['price'] is None
assert services[0]['seats'] == 0
def test_parse_graphql_null_price() -> None:
data = _gql_response([_journey("06:16", "11:09", price=None, seats=0)])
services = _parse_graphql(data, "Amsterdam Centraal")
assert services[0]["price"] is None
assert services[0]["seats"] == 0
def test_parse_graphql_sorted_by_departure():
data = _gql_response([
_journey('10:31', '13:55'),
_journey('07:31', '10:59'),
])
services = _parse_graphql(data, 'Paris Gare du Nord')
assert services[0]['depart_st_pancras'] == '07:31'
assert services[1]['depart_st_pancras'] == '10:31'
def test_parse_graphql_sorted_by_departure() -> None:
data = _gql_response(
[
_journey("10:31", "13:55"),
_journey("07:31", "10:59"),
]
)
services = _parse_graphql(data, "Paris Gare du Nord")
assert services[0]["depart_st_pancras"] == "07:31"
assert services[1]["depart_st_pancras"] == "10:31"
def test_parse_graphql_deduplicates_same_departure_time():
data = _gql_response([
_journey('06:16', '11:09', price=None, seats=0),
_journey('06:16', '11:09', price=None, seats=0),
_journey('06:16', '11:09', price=None, seats=0),
])
services = _parse_graphql(data, 'Amsterdam Centraal')
def test_parse_graphql_deduplicates_same_departure_time() -> None:
data = _gql_response(
[
_journey("06:16", "11:09", price=None, seats=0),
_journey("06:16", "11:09", price=None, seats=0),
_journey("06:16", "11:09", price=None, seats=0),
]
)
services = _parse_graphql(data, "Amsterdam Centraal")
assert len(services) == 1
def test_parse_graphql_no_legs_gives_empty_train_number():
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='')])
services = _parse_graphql(data, 'Paris Gare du Nord')
assert services[0]['train_number'] == ''
def test_parse_graphql_no_legs_gives_empty_train_number() -> None:
data = _gql_response(
[_journey("09:31", "12:55", price=156, seats=37, service_name="")]
)
services = _parse_graphql(data, "Paris Gare du Nord")
assert services[0]["train_number"] == ""
def test_parse_graphql_empty_journeys():
def test_parse_graphql_empty_journeys() -> None:
data = _gql_response([])
assert _parse_graphql(data, 'Paris Gare du Nord') == []
assert _parse_graphql(data, "Paris Gare du Nord") == []
def test_parse_graphql_inbound_leg() -> None:
data: dict[str, Any] = {
"data": {
"journeySearch": {
"inbound": {
"journeys": [
_journey(
"17:12", "18:30", price=49, seats=43, service_name="9035"
)
]
}
}
}
}
services = _parse_graphql_leg(data, "Paris Gare du Nord", "inbound", "inbound")
assert services == [
{
"depart_destination": "17:12",
"arrive_st_pancras": "18:30",
"destination": "Paris Gare du Nord",
"train_number": "ES 9035",
"price": 49.0,
"seats": 43,
"plus_price": None,
"plus_seats": None,
}
]
# ---------------------------------------------------------------------------
# search_url
# ---------------------------------------------------------------------------
def test_search_url():
url = search_url('Paris Gare du Nord', '2026-04-10')
def test_search_url() -> None:
url = search_url("Paris Gare du Nord", "2026-04-10")
assert url == (
'https://www.eurostar.com/search/uk-en'
'?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10'
"https://www.eurostar.com/search/uk-en"
"?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10"
)
def test_search_url_return() -> None:
url = search_url("Paris Gare du Nord", "2026-04-10", return_date="2026-04-17")
assert url.endswith("&outbound=2026-04-10&inbound=2026-04-17")

View file

@ -0,0 +1,560 @@
import threading
from typing import Any, Generator
import pytest
from werkzeug.serving import make_server
import app as app_module
playwright_sync = pytest.importorskip("playwright.sync_api")
sync_playwright = playwright_sync.sync_playwright
rtt_scraper: Any = app_module.rtt_scraper # type: ignore[attr-defined]
gwr_fares_scraper: Any = app_module.gwr_fares_scraper # type: ignore[attr-defined]
eurostar_scraper: Any = app_module.eurostar_scraper # type: ignore[attr-defined]
def _stub_return_data(monkeypatch: Any) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
rtt_scraper,
"fetch_from_paddington",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_paddington": "17:15",
"arrive_destination": "18:55",
"headcode": "1B99",
},
],
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date, direction="to_paddington": {
"07:00": {
"ticket": "Anytime Day Single",
"price": 138.70,
"code": "SDS",
},
"17:15": {
"ticket": "Off-Peak Single",
"price": 63.60,
"code": "SVS",
},
},
)
def fake_advance_streaming(
station_crs: str,
travel_date: str,
direction: str = "to_paddington",
) -> Generator[dict[str, Any], None, None]:
if direction == "from_paddington":
yield {
"17:15": {
"advance_std": {
"ticket": "Advance Single",
"price": 25.0,
"code": "ADV",
},
"advance_1st": {
"ticket": "1st Advance",
"price": 45.0,
"code": "AFA",
},
}
}
else:
yield {
"07:00": {
"advance_std": {
"ticket": "Advance Single",
"price": 50.0,
"code": "ADV",
},
"advance_1st": {
"ticket": "1st Advance",
"price": 80.0,
"code": "AFA",
},
}
}
monkeypatch.setattr(
gwr_fares_scraper,
"fetch_advance_streaming",
fake_advance_streaming,
)
def fake_advance(
station_crs: str, travel_date: str, direction: str = "to_paddington"
) -> dict[str, Any]:
pages = list(fake_advance_streaming(station_crs, travel_date, direction))
return pages[0] if pages else {}
monkeypatch.setattr(gwr_fares_scraper, "fetch_advance", fake_advance)
monkeypatch.setattr(
eurostar_scraper,
"fetch_return",
lambda destination, outbound_date, return_date: {
"outbound": [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": 89,
"plus_seats": 5,
},
],
"inbound": [
{
"depart_destination": "15:12",
"arrive_st_pancras": "16:30",
"destination": destination,
"train_number": "ES 9035",
"price": 49,
"seats": 43,
"plus_price": 79,
"plus_seats": 6,
},
],
},
)
def _stub_single_data(monkeypatch: Any) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date: {
"07:00": {
"ticket": "Anytime Day Single",
"price": 138.70,
"code": "SDS",
},
},
)
advance_fares: dict[str, Any] = {
"07:00": {
"advance_std": {
"ticket": "Advance Single",
"price": 50.0,
"code": "ADV",
},
"advance_1st": {
"ticket": "1st Advance",
"price": 80.0,
"code": "AFA",
},
},
}
monkeypatch.setattr(
gwr_fares_scraper,
"fetch_advance",
lambda station_crs, travel_date: advance_fares,
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch_advance_streaming",
lambda station_crs, travel_date: iter([advance_fares]),
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": 89,
"plus_seats": 5,
},
],
)
@pytest.fixture
def local_server(monkeypatch: Any) -> Generator[str, None, None]:
_stub_return_data(monkeypatch)
app_module.app.config["TESTING"] = True
server = make_server("127.0.0.1", 0, app_module.app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
thread.join(timeout=5)
@pytest.fixture
def single_server(monkeypatch: Any) -> Generator[str, None, None]:
_stub_single_data(monkeypatch)
app_module.app.config["TESTING"] = True
server = make_server("127.0.0.1", 0, app_module.app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
thread.join(timeout=5)
def _launch_browser(playwright: Any) -> Any:
try:
return playwright.chromium.launch(headless=True)
except Exception as exc:
pytest.skip(f"Chromium browser unavailable for Playwright: {exc}")
def test_single_advance_standard_totals_after_click(single_server: str) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"{single_server}/results/BRI/paris/2026-07-20",
wait_until="domcontentloaded",
)
page.get_by_role("button", name="Advance Std").click()
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£112.10'))",
timeout=10000,
)
assert "nr_class=advance_std" in page.url
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£112.10"]
browser.close()
def test_single_next_date_advance_standard_labels_unreachable_rows(
monkeypatch: Any,
) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date: {
"07:00": {
"ticket": "Anytime Day Single",
"price": 138.70,
"code": "SDS",
},
},
)
advance_fares: dict[str, Any] = {
"07:00": {
"advance_std": {
"ticket": "Advance Single",
"price": 50.0,
"code": "ADV",
},
"advance_1st": None,
},
}
monkeypatch.setattr(
gwr_fares_scraper,
"fetch_advance",
lambda station_crs, travel_date: advance_fares,
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch_advance_streaming",
lambda station_crs, travel_date: iter([advance_fares]),
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "09:30",
"arrive_destination": "12:30",
"destination": destination,
"train_number": "ES 9001",
"price": 59,
"seats": 42,
"plus_price": None,
"plus_seats": None,
},
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": None,
"plus_seats": None,
},
],
)
app_module.app.config["TESTING"] = True
server = make_server("127.0.0.1", 0, app_module.app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"http://127.0.0.1:{server.server_port}"
"/results/BRI/brussels/2026-06-16",
wait_until="domcontentloaded",
)
page.get_by_role("link", name="Next →").click()
page.wait_for_url("**/2026-06-17**", timeout=10000)
page.get_by_role("button", name="Advance Std").click()
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£112.10'))",
timeout=10000,
)
assert page.get_by_text("No connection").count() == 1
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£112.10"]
browser.close()
finally:
server.shutdown()
thread.join(timeout=5)
def test_single_advance_standard_premier_totals_on_initial_url(
single_server: str,
) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"{single_server}/results/BRI/paris/2026-07-20"
"?nr_class=advance_std&es_class=plus",
wait_until="domcontentloaded",
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£142.10'))",
timeout=10000,
)
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£142.10"]
browser.close()
def test_single_advance_first_falls_back_to_walkon_when_unavailable(
monkeypatch: Any,
) -> None:
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date: {
"07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
},
)
advance_fares: dict[str, Any] = {
"07:00": {
"advance_std": {"ticket": "Advance Single", "price": 50.0, "code": "ADV"},
"advance_1st": None,
},
}
monkeypatch.setattr(
gwr_fares_scraper,
"fetch_advance",
lambda station_crs, travel_date: advance_fares,
)
monkeypatch.setattr(
gwr_fares_scraper,
"fetch_advance_streaming",
lambda station_crs, travel_date: iter([advance_fares]),
)
monkeypatch.setattr(
eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": 89,
"plus_seats": 5,
},
],
)
app_module.app.config["TESTING"] = True
server = make_server("127.0.0.1", 0, app_module.app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"http://127.0.0.1:{server.server_port}"
"/results/BRI/paris/2026-07-20?nr_class=advance_1st&es_class=standard",
wait_until="domcontentloaded",
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£200.80'))",
timeout=10000,
)
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£200.80"]
assert "No NR fare" not in " ".join(totals)
browser.close()
finally:
server.shutdown()
thread.join(timeout=5)
def test_return_advance_first_standard_premier_totals(local_server: str) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(f"{local_server}/", wait_until="domcontentloaded")
page.locator("#journey-return").check(force=True)
page.locator("#destination-paris").check(force=True)
page.locator("#travel_date").fill("2026-07-20")
page.locator("#return_date").fill("2026-07-27")
page.locator('button[type="submit"]').click()
page.wait_for_url("**/results/**", timeout=10000)
page.get_by_role("button", name="Advance 1st").click()
page.get_by_role("button", name="Standard Premier").click()
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£172.10'))",
timeout=10000,
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£127.10'))",
timeout=10000,
)
assert "/results/BRI/paris/2026-07-20/return/2026-07-27" in page.url
assert "journey_type=return" not in page.url
assert "return_date=2026-07-27" not in page.url
assert "nr_class=advance_1st" in page.url
assert "es_class=plus" in page.url
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£172.10 💸", "£127.10 🪙"]
browser.close()
def test_return_calendar_selects_outbound_before_return(local_server: str) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(f"{local_server}/", wait_until="domcontentloaded")
page.locator("#journey-return").check(force=True)
assert page.locator("#cal-hint").inner_text() == "Select outbound date"
assert page.locator("#travel_date").input_value() == ""
assert page.locator("#return_date").input_value() == ""
page.get_by_role("button", name="10 June 2026").click()
assert page.locator("#travel_date").input_value() == "2026-06-10"
assert page.locator("#return_date").input_value() == ""
assert "Now select return date" in page.locator("#cal-hint").inner_text()
page.get_by_role("button", name="17 June 2026").click()
assert page.locator("#travel_date").input_value() == "2026-06-10"
assert page.locator("#return_date").input_value() == "2026-06-17"
assert "Return: Wed 17 Jun" in page.locator("#cal-hint").inner_text()
page.locator('button[type="submit"]').click()
page.wait_for_url("**/results/BRI/paris/2026-06-10/return/2026-06-17", timeout=10000)
browser.close()
def test_return_advance_first_standard_premier_totals_on_initial_url(
local_server: str,
) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"{local_server}/results/BRI/paris/2026-07-20/return/2026-07-27"
"?nr_class=advance_1st&es_class=plus",
wait_until="domcontentloaded",
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£172.10'))",
timeout=10000,
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£127.10'))",
timeout=10000,
)
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£172.10 💸", "£127.10 🪙"]
browser.close()

View file

@ -1,71 +1,74 @@
import pytest
from scraper.realtime_trains import _fmt, _parse_services
# ---------------------------------------------------------------------------
# _fmt
# ---------------------------------------------------------------------------
def test_fmt_four_digits():
assert _fmt('0830') == '08:30'
def test_fmt_already_colon():
assert _fmt('08:30') == '08:30'
def test_fmt_four_digits() -> None:
assert _fmt("0830") == "08:30"
def test_fmt_strips_non_digits():
assert _fmt('08h30') == '08:30'
def test_fmt_already_colon() -> None:
assert _fmt("08:30") == "08:30"
def test_fmt_strips_non_digits() -> None:
assert _fmt("08h30") == "08:30"
# ---------------------------------------------------------------------------
# _parse_services
# ---------------------------------------------------------------------------
def _make_html(services: list[tuple[str, str]], time_class: str) -> str:
"""Build a minimal servicelist HTML with (train_id, time) pairs."""
items = ''
items = ""
for tid, time in services:
items += f'''
items += f"""
<a class="service">
<div class="tid">{tid}</div>
<div class="time plan {time_class}">{time}</div>
</a>'''
</a>"""
return f'<div class="servicelist">{items}</div>'
def test_parse_services_departures():
html = _make_html([('1A23', '0700'), ('2B45', '0830')], 'd')
result = _parse_services(html, 'div.time.plan.d')
assert result == {'1A23': '07:00', '2B45': '08:30'}
def test_parse_services_departures() -> None:
html = _make_html([("1A23", "0700"), ("2B45", "0830")], "d")
result = _parse_services(html, "div.time.plan.d")
assert result == {"1A23": "07:00", "2B45": "08:30"}
def test_parse_services_arrivals():
html = _make_html([('1A23', '0845')], 'a')
result = _parse_services(html, 'div.time.plan.a')
assert result == {'1A23': '08:45'}
def test_parse_services_arrivals() -> None:
html = _make_html([("1A23", "0845")], "a")
result = _parse_services(html, "div.time.plan.a")
assert result == {"1A23": "08:45"}
def test_parse_services_no_servicelist():
assert _parse_services('<html></html>', 'div.time.plan.d') == {}
def test_parse_services_no_servicelist() -> None:
assert _parse_services("<html></html>", "div.time.plan.d") == {}
def test_parse_services_skips_missing_time():
html = '''
def test_parse_services_skips_missing_time() -> None:
html = """
<div class="servicelist">
<a class="service"><div class="tid">1A23</div></a>
<a class="service"><div class="tid">2B45</div><div class="time plan d">0900</div></a>
</div>'''
result = _parse_services(html, 'div.time.plan.d')
assert '1A23' not in result
assert result == {'2B45': '09:00'}
</div>"""
result = _parse_services(html, "div.time.plan.d")
assert "1A23" not in result
assert result == {"2B45": "09:00"}
def test_parse_services_skips_empty_time():
html = '''
def test_parse_services_skips_empty_time() -> None:
html = """
<div class="servicelist">
<a class="service">
<div class="tid">1A23</div>
<div class="time plan d"> </div>
</a>
</div>'''
result = _parse_services(html, 'div.time.plan.d')
</div>"""
result = _parse_services(html, "div.time.plan.d")
assert result == {}

View file

@ -1,64 +1,85 @@
import pytest
from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration
from trip_planner import (
combine_inbound_trips,
combine_trips,
find_unreachable_morning_eurostars,
_fmt_duration,
)
DATE = '2026-03-30'
DATE = "2026-03-30"
# ---------------------------------------------------------------------------
# _fmt_duration
# ---------------------------------------------------------------------------
def test_fmt_duration_hours_and_minutes():
assert _fmt_duration(95) == '1h 35m'
def test_fmt_duration_exact_hours():
assert _fmt_duration(120) == '2h'
def test_fmt_duration_hours_and_minutes() -> None:
assert _fmt_duration(95) == "1h 35m"
def test_fmt_duration_minutes_only():
assert _fmt_duration(45) == '45m'
def test_fmt_duration_exact_hours() -> None:
assert _fmt_duration(120) == "2h"
def test_fmt_duration_minutes_only() -> None:
assert _fmt_duration(45) == "45m"
# ---------------------------------------------------------------------------
# combine_trips — basic pairing
# ---------------------------------------------------------------------------
GWR_FAST = {'depart_bristol': '07:00', 'arrive_paddington': '08:45'} # 1h 45m
GWR_SLOW = {'depart_bristol': '07:00', 'arrive_paddington': '09:26'} # 2h 26m — connection too short for ES_PARIS
GWR_FAST = {"depart_bristol": "07:00", "arrive_paddington": "08:45"} # 1h 45m
GWR_SLOW = {
"depart_bristol": "07:00",
"arrive_paddington": "09:26",
} # 2h 26m — connection too short for ES_PARIS
ES_PARIS = {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', 'destination': 'Paris Gare du Nord'}
ES_EARLY = {'depart_st_pancras': '09:00', 'arrive_destination': '12:00', 'destination': 'Paris Gare du Nord'}
ES_PARIS = {
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": "Paris Gare du Nord",
}
ES_EARLY = {
"depart_st_pancras": "09:00",
"arrive_destination": "12:00",
"destination": "Paris Gare du Nord",
}
def test_valid_trip_is_returned():
def test_valid_trip_is_returned() -> None:
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert len(trips) == 1
t = trips[0]
assert t['depart_bristol'] == '07:00'
assert t['arrive_paddington'] == '08:45'
assert t['depart_st_pancras'] == '10:01'
assert t['arrive_destination'] == '13:34'
assert t['destination'] == 'Paris Gare du Nord'
assert t["depart_bristol"] == "07:00"
assert t["arrive_paddington"] == "08:45"
assert t["depart_st_pancras"] == "10:01"
assert t["arrive_destination"] == "13:34"
assert t["destination"] == "Paris Gare du Nord"
def test_gwr_too_slow_excluded():
def test_gwr_too_slow_excluded() -> None:
# arrive 09:26, Eurostar 10:01 → 35 min connection < 50 min minimum
trips = combine_trips([GWR_SLOW], [ES_PARIS], DATE)
assert trips == []
def test_eurostar_too_early_excluded():
def test_eurostar_too_early_excluded() -> None:
# Eurostar departs before min connection time has elapsed
trips = combine_trips([GWR_FAST], [ES_EARLY], DATE)
assert trips == []
def test_no_trains_returns_empty():
def test_no_trains_returns_empty() -> None:
assert combine_trips([], [], DATE) == []
def test_no_gwr_returns_empty():
def test_no_gwr_returns_empty() -> None:
assert combine_trips([], [ES_PARIS], DATE) == []
def test_no_eurostar_returns_empty():
def test_no_eurostar_returns_empty() -> None:
assert combine_trips([GWR_FAST], [], DATE) == []
@ -66,115 +87,211 @@ def test_no_eurostar_returns_empty():
# Connection window constraints
# ---------------------------------------------------------------------------
def test_min_connection_enforced():
def test_min_connection_enforced() -> None:
# Arrive Paddington 08:45, need 75 min → earliest St Pancras 10:00
# ES at 09:59 should be excluded, 10:00 should be included
es_too_close = {'depart_st_pancras': '09:59', 'arrive_destination': '13:00', 'destination': 'Paris Gare du Nord'}
es_ok = {'depart_st_pancras': '10:00', 'arrive_destination': '13:00', 'destination': 'Paris Gare du Nord'}
assert combine_trips([GWR_FAST], [es_too_close], DATE, min_connection_minutes=75) == []
es_too_close = {
"depart_st_pancras": "09:59",
"arrive_destination": "13:00",
"destination": "Paris Gare du Nord",
}
es_ok = {
"depart_st_pancras": "10:00",
"arrive_destination": "13:00",
"destination": "Paris Gare du Nord",
}
assert (
combine_trips([GWR_FAST], [es_too_close], DATE, min_connection_minutes=75) == []
)
trips = combine_trips([GWR_FAST], [es_ok], DATE, min_connection_minutes=75)
assert len(trips) == 1
def test_max_connection_enforced():
def test_max_connection_enforced() -> None:
# Arrive Paddington 08:45, max 140 min → latest St Pancras 11:05
es_ok = {'depart_st_pancras': '11:05', 'arrive_destination': '14:00', 'destination': 'Paris Gare du Nord'}
es_too_late = {'depart_st_pancras': '11:06', 'arrive_destination': '14:00', 'destination': 'Paris Gare du Nord'}
es_ok = {
"depart_st_pancras": "11:05",
"arrive_destination": "14:00",
"destination": "Paris Gare du Nord",
}
es_too_late = {
"depart_st_pancras": "11:06",
"arrive_destination": "14:00",
"destination": "Paris Gare du Nord",
}
trips = combine_trips([GWR_FAST], [es_ok], DATE, max_connection_minutes=140)
assert len(trips) == 1
assert combine_trips([GWR_FAST], [es_too_late], DATE, max_connection_minutes=140) == []
assert (
combine_trips([GWR_FAST], [es_too_late], DATE, max_connection_minutes=140) == []
)
# ---------------------------------------------------------------------------
# Only earliest valid Eurostar per GWR departure
# ---------------------------------------------------------------------------
def test_only_earliest_eurostar_per_gwr():
es1 = {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', 'destination': 'Paris Gare du Nord'}
es2 = {'depart_st_pancras': '11:01', 'arrive_destination': '14:34', 'destination': 'Paris Gare du Nord'}
def test_only_earliest_eurostar_per_gwr() -> None:
es1 = {
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": "Paris Gare du Nord",
}
es2 = {
"depart_st_pancras": "11:01",
"arrive_destination": "14:34",
"destination": "Paris Gare du Nord",
}
trips = combine_trips([GWR_FAST], [es1, es2], DATE)
assert len(trips) == 1
assert trips[0]['depart_st_pancras'] == '10:01'
assert trips[0]["depart_st_pancras"] == "10:01"
# ---------------------------------------------------------------------------
# Multiple GWR trains → multiple trips
# ---------------------------------------------------------------------------
def test_multiple_gwr_trains():
gwr2 = {'depart_bristol': '08:00', 'arrive_paddington': '09:45'}
es = {'depart_st_pancras': '11:01', 'arrive_destination': '14:34', 'destination': 'Paris Gare du Nord'}
def test_multiple_gwr_trains() -> None:
gwr2 = {"depart_bristol": "08:00", "arrive_paddington": "09:45"}
es = {
"depart_st_pancras": "11:01",
"arrive_destination": "14:34",
"destination": "Paris Gare du Nord",
}
trips = combine_trips([GWR_FAST, gwr2], [es], DATE, max_connection_minutes=140)
assert len(trips) == 2
assert trips[0]['depart_bristol'] == '07:00'
assert trips[1]['depart_bristol'] == '08:00'
assert trips[0]["depart_bristol"] == "07:00"
assert trips[1]["depart_bristol"] == "08:00"
# ---------------------------------------------------------------------------
# Duration fields
# ---------------------------------------------------------------------------
def test_gwr_duration_in_trip():
def test_gwr_duration_in_trip() -> None:
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert trips[0]['gwr_duration'] == '1h 45m'
assert trips[0]["gwr_duration"] == "1h 45m"
def test_total_duration_in_trip():
def test_total_duration_in_trip() -> None:
# depart 07:00, arrive 13:34 → 6h 34m
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert trips[0]['total_duration'] == '6h 34m'
assert trips[0]["total_duration"] == "6h 34m"
def test_connection_duration_in_trip():
def test_connection_duration_in_trip() -> None:
# arrive Paddington 08:45, depart St Pancras 10:01 → 1h 16m
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert trips[0]['connection_duration'] == '1h 16m'
assert trips[0]["connection_duration"] == "1h 16m"
def test_find_unreachable_eurostars_excludes_connectable_services():
def test_find_unreachable_eurostars_excludes_connectable_services() -> None:
# GWR arrives 08:45; default min=50/max=110 → viable window 09:3510:35.
# 09:30 too early, 10:15 connectable, 12:30 beyond max connection.
gwr = [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45'},
{"depart_bristol": "07:00", "arrive_paddington": "08:45"},
]
eurostar = [
{'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9001'},
{'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9002'},
{'depart_st_pancras': '12:30', 'arrive_destination': '15:55', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9003'},
{
"depart_st_pancras": "09:30",
"arrive_destination": "12:00",
"destination": "Paris Gare du Nord",
"train_number": "ES 9001",
},
{
"depart_st_pancras": "10:15",
"arrive_destination": "13:40",
"destination": "Paris Gare du Nord",
"train_number": "ES 9002",
},
{
"depart_st_pancras": "12:30",
"arrive_destination": "15:55",
"destination": "Paris Gare du Nord",
"train_number": "ES 9003",
},
]
unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE)
assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30']
assert [s["depart_st_pancras"] for s in unreachable] == ["09:30", "12:30"]
def test_combine_trips_includes_ticket_fields():
def test_combine_trips_includes_ticket_fields() -> None:
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert len(trips) == 1
t = trips[0]
assert 'ticket_name' in t
assert 'ticket_price' in t
assert 'ticket_code' in t
assert "ticket_name" in t
assert "ticket_price" in t
assert "ticket_code" in t
def test_combine_trips_uses_gwr_fares_when_provided():
fares = {'07:00': {'ticket': 'Super Off-Peak Single', 'price': 49.30, 'code': 'SSS'}}
def test_combine_trips_uses_gwr_fares_when_provided() -> None:
fares = {
"07:00": {"ticket": "Super Off-Peak Single", "price": 49.30, "code": "SSS"}
}
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares=fares)
assert len(trips) == 1
assert trips[0]['ticket_price'] == 49.30
assert trips[0]['ticket_code'] == 'SSS'
assert trips[0]["ticket_price"] == 49.30
assert trips[0]["ticket_code"] == "SSS"
def test_combine_trips_ticket_price_none_when_no_fares():
def test_combine_trips_ticket_price_none_when_no_fares() -> None:
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares={})
assert len(trips) == 1
assert trips[0]['ticket_price'] is None
assert trips[0]["ticket_price"] is None
def test_find_unreachable_eurostars_returns_empty_when_all_connectable():
def test_find_unreachable_eurostars_returns_empty_when_all_connectable() -> None:
gwr = [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45'},
{"depart_bristol": "07:00", "arrive_paddington": "08:45"},
]
eurostar = [
{'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9002'},
{
"depart_st_pancras": "10:15",
"arrive_destination": "13:40",
"destination": "Paris Gare du Nord",
"train_number": "ES 9002",
},
]
assert find_unreachable_morning_eurostars(gwr, eurostar, DATE) == []
def test_combine_inbound_trips_pairs_eurostar_to_paddington_departure() -> None:
eurostar = [
{
"depart_destination": "15:12",
"arrive_st_pancras": "16:30",
"destination": "Paris Gare du Nord",
"train_number": "ES 9035",
}
]
gwr = [
{
"depart_paddington": "17:15",
"arrive_destination": "18:55",
"headcode": "1B99",
}
]
fares = {"17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"}}
trips = combine_inbound_trips(
eurostar,
gwr,
DATE,
min_connection_minutes=30,
max_connection_minutes=120,
gwr_fares=fares,
)
assert len(trips) == 1
assert trips[0]["depart_destination"] == "15:12"
assert trips[0]["arrive_st_pancras"] == "16:30"
assert trips[0]["depart_paddington"] == "17:15"
assert trips[0]["arrive_uk_station"] == "18:55"
assert trips[0]["ticket_price"] == 63.60
assert trips[0]["check_in_by"] == "14:42"

View file

@ -3,23 +3,29 @@ Combine GWR station→Paddington trains with Eurostar St Pancras→destination t
"""
from datetime import datetime, timedelta
from typing import Any
import circle_line
from tfl_fare import circle_line_fare
MIN_CONNECTION_MINUTES = 50
MAX_CONNECTION_MINUTES = 110
INBOUND_MIN_CONNECTION_MINUTES = 30
INBOUND_MAX_CONNECTION_MINUTES = 120
DATE_FMT = "%Y-%m-%d"
TIME_FMT = "%H:%M"
PAD_WALK_TO_UNDERGROUND_MINUTES = 8 # GWR platform → Paddington (H&C Line) platform
KX_WALK_TO_UNDERGROUND_MINUTES = (
10 # St Pancras arrivals → King's Cross St Pancras Underground
)
def _parse_dt(date: str, time: str) -> datetime:
return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}")
def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
def _circle_line_services(arrive_paddington: datetime) -> list[dict[str, Any]]:
"""
Given GWR arrival at Paddington, return up to 2 upcoming Circle line services
as [{'depart': 'HH:MM', 'arrive_kx': 'HH:MM'}, ...].
@ -30,7 +36,9 @@ def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
earliest_board = arrive_paddington + timedelta(
minutes=PAD_WALK_TO_UNDERGROUND_MINUTES
)
services = circle_line.upcoming_services(earliest_board, count=2)
services = circle_line.upcoming_services(
earliest_board, count=2, direction="pad_to_kx"
)
return [
{
"depart": dep.strftime(TIME_FMT),
@ -41,6 +49,42 @@ def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
]
PAD_WALK_FROM_UNDERGROUND_MINUTES = (
5 # Circle line platform → GWR platform at Paddington
)
INBOUND_COMFORTABLE_MIN_CONN = (
40 # threshold above which we apply the platform walk buffer
)
def _circle_line_services_to_paddington(
arrive_st_pancras: datetime,
dep_paddington: datetime | None = None,
min_conn_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
) -> list[dict[str, Any]]:
earliest_board = arrive_st_pancras + timedelta(
minutes=KX_WALK_TO_UNDERGROUND_MINUTES
)
if min_conn_minutes >= INBOUND_COMFORTABLE_MIN_CONN and dep_paddington is not None:
cutoff = dep_paddington - timedelta(minutes=PAD_WALK_FROM_UNDERGROUND_MINUTES)
candidates = circle_line.upcoming_services(
earliest_board, count=4, direction="kx_to_pad"
)
services = [(dep, arr) for dep, arr in candidates if arr <= cutoff][:2]
else:
services = circle_line.upcoming_services(
earliest_board, count=1, direction="kx_to_pad", preceding=1
)
return [
{
"depart": dep.strftime(TIME_FMT),
"arrive_pad": arr.strftime(TIME_FMT),
"fare": circle_line_fare(dep),
}
for dep, arr in services
]
def _fmt_duration(minutes: int) -> str:
h, m = divmod(minutes, 60)
if h and m:
@ -51,8 +95,8 @@ def _fmt_duration(minutes: int) -> str:
def _is_viable_connection(
gwr: dict,
eurostar: dict,
gwr: dict[str, Any],
eurostar: dict[str, Any],
travel_date: str,
min_connection_minutes: int,
max_connection_minutes: int,
@ -80,14 +124,45 @@ def _is_viable_connection(
return dep_bri, arr_pad, dep_stp, arr_dest
def _is_viable_inbound_connection(
eurostar: dict[str, Any],
gwr: dict[str, Any],
travel_date: str,
min_connection_minutes: int,
max_connection_minutes: int,
) -> tuple[datetime, datetime, datetime, datetime] | None:
try:
dep_dest = _parse_dt(travel_date, eurostar["depart_destination"])
arr_stp = _parse_dt(travel_date, eurostar["arrive_st_pancras"])
dep_pad = _parse_dt(travel_date, gwr["depart_paddington"])
arr_station = _parse_dt(travel_date, gwr["arrive_destination"])
except (ValueError, KeyError):
return None
if arr_stp < dep_dest:
arr_stp += timedelta(days=1)
if dep_pad < arr_stp:
dep_pad += timedelta(days=1)
if arr_station < dep_pad:
arr_station += timedelta(days=1)
connection_minutes = (dep_pad - arr_stp).total_seconds() / 60
if connection_minutes < min_connection_minutes:
return None
if connection_minutes > max_connection_minutes:
return None
return dep_dest, arr_stp, dep_pad, arr_station
def combine_trips(
gwr_trains: list[dict],
eurostar_trains: list[dict],
gwr_trains: list[dict[str, Any]],
eurostar_trains: list[dict[str, Any]],
travel_date: str,
min_connection_minutes: int = MIN_CONNECTION_MINUTES,
max_connection_minutes: int = MAX_CONNECTION_MINUTES,
gwr_fares: dict | None = None,
) -> list[dict]:
gwr_fares: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""
Return a list of valid combined trips, sorted by Bristol departure time.
@ -117,8 +192,8 @@ def combine_trips(
continue
dep_bri, arr_pad, dep_stp, arr_dest = connection
total_mins = int((arr_dest - dep_bri).total_seconds() / 60)
# Destination time is CET/CEST, departure is GMT/BST; Europe is always 1h ahead.
total_mins = int((arr_dest - dep_bri).total_seconds() / 60) - 60
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
fare = (gwr_fares or {}).get(gwr["depart_bristol"])
circle_svcs = _circle_line_services(arr_pad)
@ -154,13 +229,79 @@ def combine_trips(
return trips
def combine_inbound_trips(
eurostar_trains: list[dict[str, Any]],
gwr_trains: list[dict[str, Any]],
travel_date: str,
min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES,
gwr_fares: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Return valid continent→UK combined trips."""
trips = []
for es in eurostar_trains:
for gwr in gwr_trains:
connection = _is_viable_inbound_connection(
es,
gwr,
travel_date,
min_connection_minutes,
max_connection_minutes,
)
if not connection:
continue
dep_dest, arr_stp, dep_pad, arr_station = connection
# Destination time is CET/CEST, arrival at London is GMT/BST; Europe is always 1h ahead.
total_mins = int((arr_station - dep_dest).total_seconds() / 60) + 60
eurostar_mins = int((arr_stp - dep_dest).total_seconds() / 60) + 60
fare = (gwr_fares or {}).get(gwr["depart_paddington"])
circle_svcs = _circle_line_services_to_paddington(
arr_stp, dep_pad, min_connection_minutes
)
trips.append(
{
"direction": "inbound",
"depart_destination": es["depart_destination"],
"check_in_by": (dep_dest - timedelta(minutes=30)).strftime(
TIME_FMT
),
"arrive_st_pancras": es["arrive_st_pancras"],
"depart_paddington": gwr["depart_paddington"],
"arrive_uk_station": gwr["arrive_destination"],
"arrive_platform": gwr.get("arrive_platform", ""),
"headcode": gwr.get("headcode", ""),
"gwr_duration": _fmt_duration(
int((arr_station - dep_pad).total_seconds() / 60)
),
"connection_minutes": int((dep_pad - arr_stp).total_seconds() / 60),
"connection_duration": _fmt_duration(
int((dep_pad - arr_stp).total_seconds() / 60)
),
"circle_services": circle_svcs,
"eurostar_duration": _fmt_duration(eurostar_mins),
"train_number": es.get("train_number", ""),
"total_duration": _fmt_duration(total_mins),
"total_minutes": total_mins,
"destination": es["destination"],
"ticket_name": fare["ticket"] if fare else None,
"ticket_price": fare["price"] if fare else None,
"ticket_code": fare["code"] if fare else None,
}
)
break
trips.sort(key=lambda t: (t["depart_destination"], t["depart_paddington"]))
return trips
def find_unreachable_morning_eurostars(
gwr_trains: list[dict],
eurostar_trains: list[dict],
gwr_trains: list[dict[str, Any]],
eurostar_trains: list[dict[str, Any]],
travel_date: str,
min_connection_minutes: int = MIN_CONNECTION_MINUTES,
max_connection_minutes: int = MAX_CONNECTION_MINUTES,
) -> list[dict]:
) -> list[dict[str, Any]]:
unreachable = []
for es in eurostar_trains:
@ -184,3 +325,35 @@ def find_unreachable_morning_eurostars(
unreachable.append({**es, "eurostar_duration": _fmt_duration(eurostar_mins)})
return sorted(unreachable, key=lambda s: s["depart_st_pancras"])
def find_unreachable_inbound_eurostars(
eurostar_trains: list[dict[str, Any]],
gwr_trains: list[dict[str, Any]],
travel_date: str,
min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES,
) -> list[dict[str, Any]]:
unreachable = []
for es in eurostar_trains:
if any(
_is_viable_inbound_connection(
es,
gwr,
travel_date,
min_connection_minutes,
max_connection_minutes,
)
for gwr in gwr_trains
):
continue
dep_dest = _parse_dt(travel_date, es["depart_destination"])
arr_stp = _parse_dt(travel_date, es["arrive_st_pancras"])
if arr_stp < dep_dest:
arr_stp += timedelta(days=1)
eurostar_mins = int((arr_stp - dep_dest).total_seconds() / 60) + 60
unreachable.append({**es, "eurostar_duration": _fmt_duration(eurostar_mins)})
return sorted(unreachable, key=lambda s: s["depart_destination"])