Compare commits

...

4 commits

7 changed files with 259 additions and 21 deletions

1
app.py
View file

@ -121,6 +121,7 @@ def results(slug, travel_date):
return render_template(
'results.html',
trips=trips,
destinations=DESTINATIONS,
destination=destination,
travel_date=travel_date,
slug=slug,

View file

@ -47,6 +47,138 @@
padding: 2rem;
}
.field-label {
display: block;
font-weight: 600;
margin-bottom: 0.4rem;
}
.form-control {
width: 100%;
padding: 0.6rem 0.8rem;
font-size: 1rem;
border: 1px solid #cbd5e0;
border-radius: 4px;
}
.fixed-station {
padding: 0.85rem 1rem;
border: 1px solid #cbd5e0;
border-radius: 10px;
background: linear-gradient(180deg, #f8fbff 0%, #eef4fa 100%);
}
.fixed-station strong {
display: block;
color: #0f172a;
font-size: 1rem;
margin-bottom: 0.2rem;
}
.fixed-station span {
display: block;
color: #475569;
font-size: 0.9rem;
}
.destination-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
}
.destination-option {
position: relative;
}
.destination-option input {
position: absolute;
opacity: 0;
inset: 0;
margin: 0;
cursor: pointer;
}
.destination-option label {
display: block;
min-height: 100%;
padding: 0.95rem 1rem;
border: 1px solid #cbd5e0;
border-radius: 10px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.destination-option label strong {
display: block;
color: #0f172a;
font-size: 1rem;
margin-bottom: 0.2rem;
}
.destination-option label span {
display: block;
color: #475569;
font-size: 0.9rem;
}
.destination-option input:hover + label {
border-color: #7aa7d9;
box-shadow: 0 4px 12px rgba(0, 83, 159, 0.08);
}
.destination-option input:focus-visible + label {
border-color: #00539f;
box-shadow: 0 0 0 3px rgba(0, 83, 159, 0.2);
}
.destination-option input:checked + label {
border-color: #00539f;
background: linear-gradient(180deg, #eef6ff 0%, #dcecff 100%);
box-shadow: 0 8px 20px rgba(0, 83, 159, 0.16);
transform: translateY(-1px);
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip-link,
.chip-current {
display: inline-block;
padding: 0.35rem 0.8rem;
border: 1px solid #cbd5e0;
border-radius: 999px;
font-size: 0.9rem;
text-decoration: none;
}
.chip-link {
color: #00539f;
background: #fff;
}
.chip-link:hover {
border-color: #7aa7d9;
background: #f8fbff;
}
.chip-current {
color: #fff;
background: #00539f;
border-color: #00539f;
font-weight: 600;
}
@media (max-width: 640px) {
.card {
padding: 1.25rem;
}
}
a { color: #00539f; }
</style>
</head>

View file

@ -4,33 +4,49 @@
<h2 style="margin-top:0">Plan your journey</h2>
<form method="get" action="{{ url_for('search') }}">
<div style="margin-bottom:1.2rem">
<label for="destination" style="display:block;font-weight:600;margin-bottom:0.4rem">
Eurostar destination
</label>
<select id="destination" name="destination" required
style="width:100%;padding:0.6rem 0.8rem;font-size:1rem;border:1px solid #cbd5e0;border-radius:4px">
<option value="" disabled selected>Select destination&hellip;</option>
<span class="field-label">Departure point</span>
<div class="fixed-station" aria-label="Departure point">
<strong>Bristol Temple Meads</strong>
<span>Fixed starting station for all journeys</span>
</div>
</div>
<div style="margin-bottom:1.2rem">
<span class="field-label">Eurostar destination</span>
<div class="destination-grid" role="radiogroup" aria-label="Eurostar destination">
{% for slug, name in destinations.items() %}
<option value="{{ slug }}">{{ name }}</option>
{% set city = name.replace(' Gare du Nord', '').replace(' Centraal', '').replace(' Midi', '').replace(' Europe', '') %}
<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>
</div>
{% endfor %}
</select>
</div>
</div>
<div style="margin-bottom:1.5rem">
<label for="travel_date" style="display:block;font-weight:600;margin-bottom:0.4rem">
<label for="travel_date" class="field-label">
Travel date
</label>
<input type="date" id="travel_date" name="travel_date" required
min="{{ today }}" value="{{ today }}"
style="width:100%;padding:0.6rem 0.8rem;font-size:1rem;border:1px solid #cbd5e0;border-radius:4px">
class="form-control">
</div>
<div style="margin-bottom:1.2rem">
<label for="min_connection" style="display:block;font-weight:600;margin-bottom:0.4rem">
<label for="min_connection" class="field-label">
Minimum connection time (Paddington &rarr; St&nbsp;Pancras)
</label>
<select id="min_connection" name="min_connection"
style="width:100%;padding:0.6rem 0.8rem;font-size:1rem;border:1px solid #cbd5e0;border-radius:4px">
<select id="min_connection" name="min_connection" class="form-control">
{% for mins in [50, 60, 70, 80, 90, 100, 110, 120] %}
<option value="{{ mins }}" {% if mins == 50 %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
@ -38,11 +54,10 @@
</div>
<div style="margin-bottom:1.5rem">
<label for="max_connection" style="display:block;font-weight:600;margin-bottom:0.4rem">
<label for="max_connection" class="field-label">
Maximum connection time (Paddington &rarr; St&nbsp;Pancras)
</label>
<select id="max_connection" name="max_connection"
style="width:100%;padding:0.6rem 0.8rem;font-size:1rem;border:1px solid #cbd5e0;border-radius:4px">
<select id="max_connection" name="max_connection" class="form-control">
{% for mins in [60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180] %}
<option value="{{ mins }}" {% if mins == 110 %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}

View file

@ -18,6 +18,21 @@
style="padding:0.3rem 0.75rem;border:1px solid #cbd5e0;border-radius:4px;
text-decoration:none;color:#00539f;font-size:0.9rem">Next &rarr;</a>
</div>
<div style="margin:0.9rem 0 1rem">
<div style="font-size:0.9rem;font-weight:600;margin-bottom:0.45rem">Switch destination for {{ travel_date_display }}</div>
<div class="chip-row">
{% for destination_slug, destination_name in destinations.items() %}
{% if destination_slug == slug %}
<span class="chip-current">{{ destination_name }}</span>
{% else %}
<a
class="chip-link"
href="{{ url_for('results', slug=destination_slug, travel_date=travel_date, min_connection=min_connection, max_connection=max_connection) }}"
>{{ destination_name }}</a>
{% endif %}
{% endfor %}
</div>
</div>
<div style="margin-top:0.75rem;display:flex;gap:1.5rem;align-items:center">
<div>
<label for="min_conn_select" style="font-size:0.9rem;font-weight:600;margin-right:0.5rem">

74
tests/test_app.py Normal file
View file

@ -0,0 +1,74 @@
import app as app_module
def _client():
app_module.app.config['TESTING'] = True
return app_module.app.test_client()
def _stub_data(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent: [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'fetch',
lambda destination, travel_date, user_agent: [
{
'depart_st_pancras': '10:01',
'arrive_destination': '13:34',
'destination': destination,
'train_number': 'ES 9014',
},
],
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'timetable_url',
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
)
def test_index_shows_fixed_departure_and_destination_radios():
client = _client()
resp = client.get('/')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert 'Departure point' in html
assert 'Bristol Temple Meads' in html
assert html.count('type="radio"') == len(app_module.DESTINATIONS)
assert 'destination-rotterdam' in html
def test_search_redirects_to_results_with_selected_params():
client = _client()
resp = client.get('/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120')
assert resp.status_code == 302
assert resp.headers['Location'].endswith(
'/results/rotterdam/2026-04-10?min_connection=60&max_connection=120'
)
def test_results_shows_same_day_destination_switcher(monkeypatch):
_stub_data(monkeypatch)
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert 'Switch destination for Friday 10 April 2026' in html
assert '<span class="chip-current">Paris Gare du Nord</span>' in html
assert '/results/brussels/2026-04-10?min_connection=60&amp;max_connection=120' in html
assert '/results/rotterdam/2026-04-10?min_connection=60&amp;max_connection=120' in html
assert 'ES 9014' in html

View file

@ -49,6 +49,7 @@ def test_parse_single_departure():
'depart_st_pancras': '06:01',
'arrive_destination': '09:34',
'destination': 'Paris Gare du Nord',
'train_number': '',
}

View file

@ -71,8 +71,8 @@ def test_min_connection_enforced():
# 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) == []
trips = combine_trips([GWR_FAST], [es_ok], DATE)
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
@ -80,9 +80,9 @@ def test_max_connection_enforced():
# 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'}
trips = combine_trips([GWR_FAST], [es_ok], DATE)
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) == []
assert combine_trips([GWR_FAST], [es_too_late], DATE, max_connection_minutes=140) == []
# ---------------------------------------------------------------------------
@ -104,7 +104,7 @@ def test_only_earliest_eurostar_per_gwr():
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'}
trips = combine_trips([GWR_FAST, gwr2], [es], DATE)
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'