Compare commits
No commits in common. "4de4c1d556f8968acf0075b5eeebda8832f3675e" and "945d028c1367ebde4abeed6d920af7614e585bab" have entirely different histories.
4de4c1d556
...
945d028c13
7 changed files with 21 additions and 259 deletions
1
app.py
1
app.py
|
|
@ -121,7 +121,6 @@ def results(slug, travel_date):
|
||||||
return render_template(
|
return render_template(
|
||||||
'results.html',
|
'results.html',
|
||||||
trips=trips,
|
trips=trips,
|
||||||
destinations=DESTINATIONS,
|
|
||||||
destination=destination,
|
destination=destination,
|
||||||
travel_date=travel_date,
|
travel_date=travel_date,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|
|
||||||
|
|
@ -47,138 +47,6 @@
|
||||||
padding: 2rem;
|
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; }
|
a { color: #00539f; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -4,49 +4,33 @@
|
||||||
<h2 style="margin-top:0">Plan your journey</h2>
|
<h2 style="margin-top:0">Plan your journey</h2>
|
||||||
<form method="get" action="{{ url_for('search') }}">
|
<form method="get" action="{{ url_for('search') }}">
|
||||||
<div style="margin-bottom:1.2rem">
|
<div style="margin-bottom:1.2rem">
|
||||||
<span class="field-label">Departure point</span>
|
<label for="destination" style="display:block;font-weight:600;margin-bottom:0.4rem">
|
||||||
<div class="fixed-station" aria-label="Departure point">
|
Eurostar destination
|
||||||
<strong>Bristol Temple Meads</strong>
|
</label>
|
||||||
<span>Fixed starting station for all journeys</span>
|
<select id="destination" name="destination" required
|
||||||
</div>
|
style="width:100%;padding:0.6rem 0.8rem;font-size:1rem;border:1px solid #cbd5e0;border-radius:4px">
|
||||||
</div>
|
<option value="" disabled selected>Select destination…</option>
|
||||||
|
|
||||||
<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() %}
|
{% for slug, name in destinations.items() %}
|
||||||
{% set city = name.replace(' Gare du Nord', '').replace(' Centraal', '').replace(' Midi', '').replace(' Europe', '') %}
|
<option value="{{ slug }}">{{ name }}</option>
|
||||||
<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 %}
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:1.5rem">
|
<div style="margin-bottom:1.5rem">
|
||||||
<label for="travel_date" class="field-label">
|
<label for="travel_date" style="display:block;font-weight:600;margin-bottom:0.4rem">
|
||||||
Travel date
|
Travel date
|
||||||
</label>
|
</label>
|
||||||
<input type="date" id="travel_date" name="travel_date" required
|
<input type="date" id="travel_date" name="travel_date" required
|
||||||
min="{{ today }}" value="{{ today }}"
|
min="{{ today }}" value="{{ today }}"
|
||||||
class="form-control">
|
style="width:100%;padding:0.6rem 0.8rem;font-size:1rem;border:1px solid #cbd5e0;border-radius:4px">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:1.2rem">
|
<div style="margin-bottom:1.2rem">
|
||||||
<label for="min_connection" class="field-label">
|
<label for="min_connection" style="display:block;font-weight:600;margin-bottom:0.4rem">
|
||||||
Minimum connection time (Paddington → St Pancras)
|
Minimum connection time (Paddington → St Pancras)
|
||||||
</label>
|
</label>
|
||||||
<select id="min_connection" name="min_connection" class="form-control">
|
<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">
|
||||||
{% for mins in [50, 60, 70, 80, 90, 100, 110, 120] %}
|
{% for mins in [50, 60, 70, 80, 90, 100, 110, 120] %}
|
||||||
<option value="{{ mins }}" {% if mins == 50 %}selected{% endif %}>{{ mins }} min</option>
|
<option value="{{ mins }}" {% if mins == 50 %}selected{% endif %}>{{ mins }} min</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -54,10 +38,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:1.5rem">
|
<div style="margin-bottom:1.5rem">
|
||||||
<label for="max_connection" class="field-label">
|
<label for="max_connection" style="display:block;font-weight:600;margin-bottom:0.4rem">
|
||||||
Maximum connection time (Paddington → St Pancras)
|
Maximum connection time (Paddington → St Pancras)
|
||||||
</label>
|
</label>
|
||||||
<select id="max_connection" name="max_connection" class="form-control">
|
<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">
|
||||||
{% for mins in [60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180] %}
|
{% 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>
|
<option value="{{ mins }}" {% if mins == 110 %}selected{% endif %}>{{ mins }} min</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -18,21 +18,6 @@
|
||||||
style="padding:0.3rem 0.75rem;border:1px solid #cbd5e0;border-radius:4px;
|
style="padding:0.3rem 0.75rem;border:1px solid #cbd5e0;border-radius:4px;
|
||||||
text-decoration:none;color:#00539f;font-size:0.9rem">Next →</a>
|
text-decoration:none;color:#00539f;font-size:0.9rem">Next →</a>
|
||||||
</div>
|
</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 style="margin-top:0.75rem;display:flex;gap:1.5rem;align-items:center">
|
||||||
<div>
|
<div>
|
||||||
<label for="min_conn_select" style="font-size:0.9rem;font-weight:600;margin-right:0.5rem">
|
<label for="min_conn_select" style="font-size:0.9rem;font-weight:600;margin-right:0.5rem">
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
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&max_connection=120' in html
|
|
||||||
assert '/results/rotterdam/2026-04-10?min_connection=60&max_connection=120' in html
|
|
||||||
assert 'ES 9014' in html
|
|
||||||
|
|
@ -49,7 +49,6 @@ def test_parse_single_departure():
|
||||||
'depart_st_pancras': '06:01',
|
'depart_st_pancras': '06:01',
|
||||||
'arrive_destination': '09:34',
|
'arrive_destination': '09:34',
|
||||||
'destination': 'Paris Gare du Nord',
|
'destination': 'Paris Gare du Nord',
|
||||||
'train_number': '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,8 @@ def test_min_connection_enforced():
|
||||||
# ES at 09:59 should be excluded, 10:00 should be included
|
# 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_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'}
|
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) == []
|
assert combine_trips([GWR_FAST], [es_too_close], DATE) == []
|
||||||
trips = combine_trips([GWR_FAST], [es_ok], DATE, min_connection_minutes=75)
|
trips = combine_trips([GWR_FAST], [es_ok], DATE)
|
||||||
assert len(trips) == 1
|
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
|
# 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_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_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)
|
trips = combine_trips([GWR_FAST], [es_ok], DATE)
|
||||||
assert len(trips) == 1
|
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) == []
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -104,7 +104,7 @@ def test_only_earliest_eurostar_per_gwr():
|
||||||
def test_multiple_gwr_trains():
|
def test_multiple_gwr_trains():
|
||||||
gwr2 = {'depart_bristol': '08:00', 'arrive_paddington': '09:45'}
|
gwr2 = {'depart_bristol': '08:00', 'arrive_paddington': '09:45'}
|
||||||
es = {'depart_st_pancras': '11:01', 'arrive_destination': '14:34', 'destination': 'Paris Gare du Nord'}
|
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)
|
trips = combine_trips([GWR_FAST, gwr2], [es], DATE)
|
||||||
assert len(trips) == 2
|
assert len(trips) == 2
|
||||||
assert trips[0]['depart_bristol'] == '07:00'
|
assert trips[0]['depart_bristol'] == '07:00'
|
||||||
assert trips[1]['depart_bristol'] == '08:00'
|
assert trips[1]['depart_bristol'] == '08:00'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue