trip: redesign itinerary display and add trip list macro

- Add render_trip_element macro to macros.html; use it in trip_item for
  the trip list page, giving a consistent one-line-per-element format
  with emoji, route, times, duration, operator, distance, and CO₂
- Redesign trip_page.html itinerary: day headers use date-only (no year),
  condense check-out to a single accent line, show time-only on transport
  cards, humanise duration (Xh Ym), km-only distance, add CO₂ for all
  transport modes, fix seat display for integer seat values
- Fix UndefinedError on /trip/past caused by absent 'arrive' key (Jinja2
  Undefined is truthy) and date-only depart/arrive fields (no .date())
- Improve mobile map layout: text column before map in HTML order, reduce
  mobile map heights, hide toggle button on mobile
- Add trips.css with design system (Playfair Display / Source Sans 3 /
  JetBrains Mono, navy/gold/amber palette, card variants by type)
- Add tests/test_trip_list_render.py covering the rendering edge cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-03-03 00:54:42 +00:00
parent b3975dad3a
commit 574b4feb1f
5 changed files with 935 additions and 338 deletions

381
static/css/trips.css Normal file
View file

@ -0,0 +1,381 @@
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Source+Sans+3:ital,wght@0,300;0,400;0,600;1,300&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--t-navy: #1e2d4a;
--t-slate: #374869;
--t-gold: #b8860b;
--t-amber: #e8a820;
--t-cream: #f9f7f3;
--t-white: #ffffff;
--t-muted: #7a8aa8;
--t-border: #dde3ed;
--t-text: #1e2533;
--t-shadow: rgba(30, 45, 74, 0.08);
}
/* Text pane background on list page */
.text-content {
background: var(--t-cream) !important;
padding-left: 12px;
padding-top: 12px;
}
/* ===========================
TRIP CARDS (list view)
=========================== */
.trip-card {
background: var(--t-white);
border-radius: 8px;
border: 1px solid var(--t-border);
border-left: 3px solid #8098c0;
box-shadow: 0 1px 4px var(--t-shadow);
padding: 14px 18px;
margin-bottom: 14px;
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.trip-card:hover {
box-shadow: 0 4px 16px rgba(30, 45, 74, 0.14);
transform: translateY(-1px);
}
.trip-card.trip-current {
border-left-color: var(--t-gold);
}
/* Trip name heading */
.trip-name {
margin-bottom: 2px;
font-size: 1.1rem;
line-height: 1.3;
}
.trip-name a {
font-family: 'Playfair Display', Georgia, 'Times New Roman', serif;
font-weight: 700;
color: var(--t-navy);
text-decoration: none;
}
.trip-name a:hover {
color: var(--t-gold);
}
.trip-name small {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 0.7rem;
color: var(--t-muted);
font-weight: 400;
}
/* Countries as inline chips */
.trip-countries {
display: flex !important;
flex-wrap: wrap;
gap: 5px;
margin: 6px 0;
padding: 0;
}
.trip-countries li {
display: inline-flex;
align-items: center;
gap: 4px;
background: #eef2f8;
border: 1px solid #d5dce8;
border-radius: 20px;
padding: 1px 10px;
font-size: 0.8rem;
color: var(--t-text);
}
/* Dates */
.trip-dates {
font-size: 0.83rem;
color: var(--t-muted);
margin: 2px 0;
}
/* Stats row — pill chips */
.trip-stats {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 7px 0;
}
.trip-stat {
display: inline-block;
background: #f3f6fa;
border: 1px solid #dde3ed;
border-radius: 20px;
padding: 2px 10px;
font-size: 0.74rem;
color: var(--t-slate);
white-space: nowrap;
font-family: 'JetBrains Mono', monospace;
}
/* School holiday info */
.school-holiday-info {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 4px 0 8px;
font-size: 0.82rem;
}
/* Inline weather chip in day headers */
.trip-weather-inline {
font-size: 0.78rem;
color: var(--t-muted);
font-weight: 400;
letter-spacing: 0;
text-transform: none;
display: inline-flex;
align-items: center;
gap: 3px;
vertical-align: middle;
}
/* Day sub-headers within a trip card */
.trip-day-header {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--t-navy);
margin: 18px 0 6px;
padding: 4px 0 4px 10px;
border-left: 3px solid var(--t-amber);
background: linear-gradient(to right, rgba(232, 168, 32, 0.07), transparent);
}
/* Condensed check-out line */
.trip-checkout {
font-size: 0.84rem;
padding: 4px 8px;
color: var(--t-muted);
border-left: 2px solid #aacde8;
margin: 3px 0 3px 1px;
}
/* Transport/accommodation element rows */
.trip-element {
font-size: 0.84rem;
padding: 2px 0;
color: var(--t-text);
line-height: 1.5;
}
/* ===========================
INLINE CONFERENCE CARDS
(within trip_item macro)
=========================== */
.trip-conference-card {
background: #fffef4;
border: 1px solid #e4d46c;
border-radius: 6px;
padding: 9px 13px;
margin: 5px 0;
}
.trip-conference-card .card-body {
padding: 0;
}
.trip-conference-card .card-title {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 3px;
color: var(--t-navy);
}
.trip-conference-card .card-text {
font-size: 0.82rem;
margin-bottom: 0;
color: var(--t-text);
}
/* ===========================
TRIP PAGE ACCOMMODATION
=========================== */
.trip-accommodation-card {
background: #f3faff;
border: 1px solid #aacde8;
border-radius: 6px;
padding: 9px 13px;
margin: 5px 0;
}
.trip-accommodation-card .card-body { padding: 0; }
.trip-accommodation-card .card-title {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 3px;
color: var(--t-navy);
}
.trip-accommodation-card .card-text {
font-size: 0.82rem;
margin-bottom: 0;
}
/* ===========================
TRIP PAGE TRANSPORT
=========================== */
.trip-transport-card {
background: #f7f9fc;
border: 1px solid #d0dbe8;
border-radius: 6px;
padding: 9px 13px;
margin: 5px 0;
}
.trip-transport-card .card-body { padding: 0; }
.trip-transport-card .card-title {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 3px;
color: var(--t-navy);
}
.trip-transport-card .card-text {
font-size: 0.82rem;
margin-bottom: 0;
}
/* ===========================
TRIP PAGE EVENTS
=========================== */
.trip-event-card {
background: #fdf5ff;
border: 1px solid #d4a8e8;
border-radius: 6px;
padding: 9px 13px;
margin: 5px 0;
}
.trip-event-card .card-body { padding: 0; }
.trip-event-card .card-title {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 3px;
color: var(--t-navy);
}
.trip-event-card .card-text {
font-size: 0.82rem;
margin-bottom: 0;
}
/* ===========================
TRIP PAGE HEADER & TYPOGRAPHY
=========================== */
.trip-page-title {
font-family: 'Playfair Display', Georgia, 'Times New Roman', serif;
font-weight: 700;
font-size: 1.8rem;
color: var(--t-navy);
margin-bottom: 4px;
}
/* Section divider headings on the trip detail page */
h3.trip-section-h,
h4.trip-section-h {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--t-muted);
margin-top: 22px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--t-border);
}
/* Prev/next nav */
.trip-prev-next {
font-size: 0.83rem;
color: var(--t-muted);
margin-bottom: 16px;
}
.trip-prev-next a {
color: var(--t-slate);
text-decoration: none;
font-weight: 500;
}
.trip-prev-next a:hover {
color: var(--t-gold);
}
/* ===========================
TRIP LIST PAGE SUMMARY BOX
=========================== */
.trip-list-summary {
background: var(--t-navy);
color: #c8d4e8;
border-radius: 8px;
padding: 14px 18px;
margin-bottom: 18px;
margin-top: 8px;
}
.trip-list-summary h2 {
color: #f0f4fa;
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.25rem;
margin-bottom: 4px;
font-weight: 700;
}
.trip-list-summary a {
color: var(--t-amber);
text-decoration: none;
font-size: 0.82rem;
}
.trip-list-summary a:hover {
text-decoration: underline;
}
.summary-stats-row {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin-top: 10px;
}
.summary-stat {
display: flex;
flex-direction: column;
}
.summary-stat-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.55;
line-height: 1;
margin-bottom: 2px;
}
.summary-stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.82rem;
color: var(--t-amber);
font-weight: 500;
}

View file

@ -359,7 +359,7 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower
{% macro conference_list(trip) %}
{% for item in trip.conferences %}
{% set country = get_country(item.country) if item.country else None %}
<div class="card my-1">
<div class="trip-conference-card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.name }}</a>
@ -394,34 +394,81 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower
{% endfor %}
{% endmacro %}
{% macro render_trip_element(e, trip) %}
{% set item = e.detail %}
{% if e.element_type == "check-in" %}
{% set nights = (item.to.date() - item.from.date()).days %}
<div class="trip-element">
{{ e.get_emoji() }} <strong>{{ item.name }}</strong>
{% if item.operator and item.operator != item.name %}<small class="text-muted">{{ item.operator }}</small>{% endif %}
<small class="text-muted">({% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %})</small>
</div>
{% elif e.element_type == "check-out" %}
<div class="trip-checkout">
{{ e.get_emoji() }} Check out: {{ item.name }}
{% if item.operator and item.operator != item.name %}<span class="text-muted small">{{ item.operator }}</span>{% endif %}
</div>
{% elif e.element_type != "conference" %}
{# Transport: flight, train, ferry, coach, bus #}
{% set has_arrive = item.arrive is defined and item.arrive %}
{# item.depart may be a date (no .date() method) or a datetime (has .date()) #}
{% set has_time = item.depart is defined and item.depart and item.depart.hour is defined %}
{% set depart_date = item.depart.date() if has_time else item.depart %}
{% set arrive_date = item.arrive.date() if (has_arrive and item.arrive.hour is defined) else item.arrive %}
{% set is_overnight = has_arrive and depart_date != arrive_date %}
{% set dur_mins = ((item.arrive - item.depart).total_seconds() // 60) | int if (has_time and has_arrive) else none %}
<div class="trip-element">
{% if is_overnight %}🌙{% else %}{{ e.get_emoji() }}{% endif %}
{{ e.start_loc }} → {{ e.end_loc }}
{% if has_time %}
· {{ item.depart.strftime("%H:%M") }}{% if has_arrive and item.arrive.hour is defined %} → {{ item.arrive.strftime("%H:%M") }}{% if is_overnight %} <span class="text-muted small">+1 day</span>{% endif %}{% endif %}
{% endif %}
{% if dur_mins %}
{%- set h = dur_mins // 60 %}{%- set m = dur_mins % 60 %}
<span class="text-muted">🕒{% if h %}{{ h }}h {% endif %}{% if m %}{{ m }}m{% endif %}</span>
{% endif %}
{% if e.element_type == "flight" %}
<small class="text-muted">· {{ item.airline_name }} {{ item.airline_code }}{{ item.flight_number }}</small>
{% elif item.operator %}
<small class="text-muted">· {{ item.operator }}</small>
{% endif %}
{% if item.distance %}
<span class="text-muted small">· {{ "{:,.0f} km".format(item.distance) }}</span>
{% endif %}
{% if item.co2_kg is defined and item.co2_kg is not none %}
<span class="text-muted small">CO₂ {{ "{:,.1f}".format(item.co2_kg) }} kg</span>
{% endif %}
</div>
{% endif %}
{% endmacro %}
{% macro trip_item(trip) %}
{% set distances_by_transport_type = trip.distances_by_transport_type() %}
{% set total_distance = trip.total_distance() %}
{% set total_co2_kg = trip.total_co2_kg() %}
{% set end = trip.end %}
<div class="border border-2 rounded mb-2 p-2">
<h3>
{% set trip_end = end or trip.start %}
{% set is_current = trip.start <= today and trip_end >= today %}
<div class="trip-card{% if is_current %} trip-current{% endif %}">
<h3 class="trip-name">
{{ trip_link(trip) }}
<small class="text-muted">({{ display_date(trip.start) }})</small></h3>
<small>({{ display_date(trip.start) }})</small></h3>
{% set school_holidays = trip_school_holiday_map.get(trip.start.isoformat(), []) if trip_school_holiday_map is defined else [] %}
{% if school_holidays %}
<div>
<div class="school-holiday-info">
<span class="badge bg-warning text-dark">UK school holiday</span>
{% for item in school_holidays %}
<span class="text-muted">{{ item.title }} ({{ display_date_no_year(item.as_date) }} to {{ display_date_no_year(item.end_as_date) }})</span>
{% endfor %}
</div>
{% endif %}
<ul class="list-unstyled">
<ul class="list-unstyled trip-countries">
{% for c in trip.countries %}
<li>
{{ c.name }}
{{ c.flag }}
</li>
<li>{{ c.flag }} {{ c.name }}</li>
{% endfor %}
</ul>
{% if end %}
<div>Dates: {{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}
<div class="trip-dates">Dates: {{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}
{% if g.user.is_authenticated and trip.start <= today %}
<a href="https://photos.4angle.com/search?query=%7B%22takenAfter%22%3A%22{{trip.start}}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22{{end}}T23%3A59%3A59.999Z%22%7D">photos</a>
{% endif %}
@ -429,36 +476,25 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower
{% else %}
<div>Start: {{ display_date_no_year(trip.start) }} (end date missing)</div>
{% endif %}
<div class="trip-stats">
{% if total_distance %}
<div>
Total distance:
{{ format_distance(total_distance) }}
</div>
<span class="trip-stat">{{ format_distance(total_distance) }}</span>
{% endif %}
{% if distances_by_transport_type %}
{% for transport_type, distance in distances_by_transport_type %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
</div>
<span class="trip-stat">{{ transport_type | title }}: {{format_distance(distance) }}</span>
{% endfor %}
{% endif %}
{% if total_co2_kg %}
<div>Total CO₂: {{ "{:,.1f}".format(total_co2_kg) }} kg</div>
<span class="trip-stat">CO₂ {{ "{:,.1f}".format(total_co2_kg) }} kg</span>
{% endif %}
{% set co2_by_transport = trip.co2_by_transport_type() %}
{% if co2_by_transport %}
{% for transport_type, co2_kg in co2_by_transport %}
<div>
{{ transport_type | title }}
CO₂: {{ "{:,.1f}".format(co2_kg) }} kg
</div>
<span class="trip-stat">{{ transport_type | title }} CO₂ {{ "{:,.1f}".format(co2_kg) }} kg</span>
{% endfor %}
{% endif %}
</div>
{% if trip.schengen_compliance %}
<div>
@ -477,62 +513,21 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower
{% set trip_weather = trip_weather_map.get(trip.start.isoformat(), {}) if trip_weather_map is defined else {} %}
{% for day, elements in trip.elements_grouped_by_day() %}
{% set weather = trip_weather.get(day.isoformat()) %}
<h4>{{ display_date_no_year(day) }}
<h4 class="trip-day-header">{{ display_date_no_year(day) }}
{% if weather %}
<small class="text-muted">
<img src="https://openweathermap.org/img/wn/{{ weather.icon }}.png" alt="{{ weather.status }}" title="{{ weather.detailed_status }}" width="25" height="25">
{{ weather.temp_min }}{{ weather.temp_max }}°C {{ weather.detailed_status }}
</small>
{% endif %}
{% if g.user.is_authenticated and day <= today %}
<span class="lead">
<a href="https://photos.4angle.com/search?query=%7B%22takenAfter%22%3A%22{{day}}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22{{day}}T23%3A59%3A59.999Z%22%7D">photos</a>
<span class="trip-weather-inline">
<img src="https://openweathermap.org/img/wn/{{ weather.icon }}.png" alt="{{ weather.status }}" title="{{ weather.detailed_status }}" width="18" height="18">
{{ weather.temp_min }}{{ weather.temp_max }}°C
<span class="fw-normal fst-italic">{{ weather.detailed_status }}</span>
</span>
{% endif %}
</h4>
{% set accommodation_label = {"check-in": "check-in from", "check-out": "check-out by"} %}
{% for e in elements %}
{% if e.element_type in accommodation_label %}
{% set c = get_country(e.detail.country) %}
<div>
{{ e.get_emoji() }} {{ e.title }} {{ flag(trip, c.flag) }}
({{ accommodation_label[e.element_type] }} {{ display_time(e.start_time) }})
</div>
{% else %}
<div>
{{ e.get_emoji() }}
{{ display_time(e.start_time) }}
&ndash;
{{ e.start_loc }} {{ flag(trip, e.start_country.flag) }}
{{ display_time(e.end_time) }}
&ndash;
{{ e.end_loc }} {{ flag(trip, e.end_country.flag) }}
{% if e.element_type == "flight" %}
{% set flight = e.detail %}
{% set full_flight_number = flight.airline_code + flight.flight_number %}
{% set radarbox_url = "https://www.radarbox.com/data/flights/" + full_flight_number %}
<span class="text-nowrap"><strong>airline:</strong> {{ flight.airline_name }}</span>
<span class="text-nowrap"><strong>flight number:</strong> {{ full_flight_number }}</span>
{% if flight.duration %}
<span class="text-nowrap"><strong>duration:</strong> {{ flight.duration }}</span>
{% endif %}
{# <pre>{{ flight | pprint }}</pre> #}
{% if flight.co2_kg is defined and flight.co2_kg is not none %}
<span class="text-nowrap"><strong>CO₂:</strong> {{ "{:,.1f}".format(flight.co2_kg) }} kg</span>
{% endif %}
{% endif %}
{% if e.detail.distance %}
<span class="text-nowrap"><strong>distance:</strong> {{ format_distance(e.detail.distance) }}</span>
{% endif %}
{% if e.element_type == "flight" %}
<a href="{{ flightradar24_url(flight) }}">flightradar24</a>
| <a href="https://uk.flightaware.com/live/flight/{{ full_flight_number }}">FlightAware</a>
| <a href="{{ radarbox_url }}">radarbox</a>
{% endif %}
</div>
{% if g.user.is_authenticated and day <= today %}
<a class="ms-2 small fw-normal" href="https://photos.4angle.com/search?query=%7B%22takenAfter%22%3A%22{{day}}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22{{day}}T23%3A59%3A59.999Z%22%7D">photos</a>
{% endif %}
{% endfor %}
</h4>
{% for e in elements %}
{{ render_trip_element(e, trip) }}
{% endfor %}
{% endfor %}
</div>

View file

@ -7,6 +7,7 @@
{% block style %}
<link rel="stylesheet" href="{{ url_for("static", filename="leaflet/leaflet.css") }}">
<link rel="stylesheet" href="{{ url_for("static", filename="css/trips.css") }}">
<style>
body, html {
@ -37,7 +38,7 @@
.map-container {
position: relative;
top: 0;
height: 50vh; /* Adjust as needed */
height: 40vh;
}
.text-content {
height: auto;
@ -50,26 +51,32 @@
{% macro section(heading, item_list) %}
{% if item_list %}
{% set items = item_list | list %}
<div class="heading"><h2>{{ heading }}</h2></div>
<p><a href="{{ url_for("trip_stats") }}">Trip statistics</a></p>
<p>{{ items | count }} trips</p>
<div>Total distance: {{ format_distance(total_distance) }}</div>
{% for transport_type, distance in distances_by_transport_type %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
<div class="trip-list-summary">
<h2>{{ heading }}</h2>
<p class="mb-2"><a href="{{ url_for("trip_stats") }}">Trip statistics</a> &middot; {{ items | count }} trips</p>
<div class="summary-stats-row">
<div class="summary-stat">
<span class="summary-stat-label">Total distance</span>
<span class="summary-stat-value">{{ format_distance(total_distance) }}</span>
</div>
{% for transport_type, distance in distances_by_transport_type %}
<div class="summary-stat">
<span class="summary-stat-label">{{ transport_type | title }}</span>
<span class="summary-stat-value">{{ format_distance(distance) }}</span>
</div>
{% endfor %}
<div class="summary-stat">
<span class="summary-stat-label">Total CO₂</span>
<span class="summary-stat-value">{{ "{:,.1f}".format(total_co2_kg / 1000.0) }} t</span>
</div>
{% for transport_type, co2_kg in co2_by_transport_type %}
<div class="summary-stat">
<span class="summary-stat-label">{{ transport_type | title }} CO₂</span>
<span class="summary-stat-value">{{ "{:,.1f}".format(co2_kg) }} kg</span>
</div>
{% endfor %}
</div>
{% endfor %}
<div>Total CO₂: {{ "{:,.1f}".format(total_co2_kg / 1000.0) }} tonnes</div>
{% for transport_type, co2_kg in co2_by_transport_type %}
<div>
{{ transport_type | title }}
CO₂: {{ "{:,.1f}".format(co2_kg) }} kg
</div>
{% endfor %}
</div>
{% for trip in items %}
{{ trip_item(trip) }}
@ -80,12 +87,12 @@
{% block content %}
<div class="container-fluid d-flex flex-column flex-md-row">
<div class="map-container col-12 col-md-6 order-1 order-md-2">
<div id="map" class="map"></div>
</div>
<div class="text-content col-12 col-md-6 order-2 order-md-1 pe-3">
<div class="text-content col-12 col-md-6 pe-3">
{{ section(heading, trips) }}
</div>
<div class="map-container col-12 col-md-6">
<div id="map" class="map"></div>
</div>
</div>
{% endblock %}

View file

@ -6,6 +6,14 @@
{% set row = {"flight": flight_row, "train": train_row, "ferry": ferry_row, "coach": coach_row, "bus": bus_row} %}
{% macro trip_duration(depart, arrive) -%}
{%- set mins = ((arrive - depart).total_seconds() // 60) | int -%}
{%- set h = mins // 60 -%}
{%- set m = mins % 60 -%}
{%- if h %}{{ h }}h {% endif -%}
{%- if m %}{{ m }}m{% elif h %}0m{% endif -%}
{%- endmacro %}
{% macro next_and_previous() %}
<p>
{% if prev_trip %}
@ -22,6 +30,7 @@
{% if coordinates %}
<link rel="stylesheet" href="{{ url_for("static", filename="leaflet/leaflet.css") }}">
{% endif %}
<link rel="stylesheet" href="{{ url_for("static", filename="css/trips.css") }}">
{% set conference_column_count = 7 %}
{% set accommodation_column_count = 7 %}
@ -57,19 +66,28 @@
}
.full-window-map {
position: fixed; /* Make the map fixed position */
position: fixed;
top: 56px;
left: 0;
right: 0;
bottom: 0;
z-index: 9999; /* Make sure it sits on top */
z-index: 9999;
}
#toggleMapSize {
position: fixed; /* Fixed position */
top: 66px; /* 10px from the top */
right: 10px; /* 10px from the right */
z-index: 10000; /* Higher than the map's z-index */
position: fixed;
top: 66px;
right: 10px;
z-index: 10000;
}
@media (max-width: 767.98px) {
#toggleMapSize {
display: none;
}
.half-map {
height: 50vh;
}
}
</style>
@ -85,8 +103,8 @@
<div class="row">
<div class="col-md-6 col-sm-12">
<div class="m-3">
{{ next_and_previous() }}
<h1>{{ trip.title }}</h1>
<div class="trip-prev-next">{{ next_and_previous() }}</div>
<h1 class="trip-page-title">{{ trip.title }}</h1>
<p class="lead">
{% if end %}
{{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}
@ -97,8 +115,11 @@
</p>
<div class="mb-3">
{# <div>Countries: {{ trip.countries_str }}</div> #}
<div>Locations: {{ trip.locations_str }}</div>
<ul class="list-unstyled trip-countries">
{% for location, country in trip.locations() %}
<li>{{ country.flag if trip.show_flags }} {{ location }}</li>
{% endfor %}
</ul>
{% if destination_times %}
<div class="mt-2">
<strong>Destination time zones</strong>
@ -123,31 +144,24 @@
</div>
{% endif %}
<div class="trip-stats">
{% if total_distance %}
<div>Total distance:
{{ "{:,.0f} km / {:,.0f} miles".format(total_distance, total_distance / 1.60934) }}
</div>
<span class="trip-stat">{{ "{:,.0f} km / {:,.0f} mi".format(total_distance, total_distance / 1.60934) }}</span>
{% endif %}
{% if distances_by_transport_type %}
{% for transport_type, distance in distances_by_transport_type %}
<div>{{ transport_type | title }} distance:
{{ "{:,.0f} km / {:,.0f} miles".format(distance, distance / 1.60934) }}
</div>
<span class="trip-stat">{{ transport_type | title }}: {{ "{:,.0f} km".format(distance) }}</span>
{% endfor %}
{% endif %}
{% if total_co2_kg %}
<div>Total CO₂: {{ "{:,.1f}".format(total_co2_kg) }} kg</div>
<span class="trip-stat">CO₂ {{ "{:,.1f}".format(total_co2_kg) }} kg</span>
{% endif %}
{% if co2_by_transport_type %}
{% for transport_type, co2_kg in co2_by_transport_type %}
<div>{{ transport_type | title }} CO₂:
{{ "{:,.1f}".format(co2_kg) }} kg
</div>
<span class="trip-stat">{{ transport_type | title }} CO₂ {{ "{:,.1f}".format(co2_kg) }} kg</span>
{% endfor %}
{% endif %}
</div>
{% set delta = human_readable_delta(trip.start) %}
@ -175,75 +189,227 @@
{% endif %}
</div>
{% for item in trip.conferences %}
{% set country = get_country(item.country) if item.country else None %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.name }}</a>
<small class="text-muted">
{{ display_conf_date_no_year(item.attend_start if item.attend_start else item.start) }} to {{ display_conf_date_no_year(item.attend_end if item.attend_end else item.end) }}
{% if item.attend_start or item.attend_end %}
(full conference: {{ display_date_no_year(item.start) }} to {{ display_date_no_year(item.end) }})
{% endif %}
</small>
</h5>
<p class="card-text">
<strong>Topic:</strong> {{ item.topic }}
<strong>Venue:</strong> {{ item.venue }}
<strong>Location:</strong> {{ item.location }}
{% if country %}
{{ country.flag if trip.show_flags }}
{% elif item.online %}
💻 Online
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if item.free %}
<span class="badge bg-success text-nowrap">free to attend</span>
{% elif item.price and item.currency %}
<span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{# ---- Chronological itinerary ---- #}
{% for day, day_elements in trip.elements_grouped_by_day() %}
{% set weather = trip_weather.get(day.isoformat()) if trip_weather else None %}
<h4 class="trip-day-header">
{{ display_date_no_year(day) }}
{% if weather %}
<span class="trip-weather-inline">
<img src="https://openweathermap.org/img/wn/{{ weather.icon }}.png"
alt="{{ weather.status }}" title="{{ weather.detailed_status }}" width="18" height="18">
{{ weather.temp_min }}{{ weather.temp_max }}°C
<span class="fw-normal fst-italic">{{ weather.detailed_status }}</span>
</span>
{% endif %}
{% if g.user.is_authenticated and day <= today %}
<a class="ms-2 small fw-normal" href="https://photos.4angle.com/search?query=%7B%22takenAfter%22%3A%22{{day}}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22{{day}}T23%3A59%3A59.999Z%22%7D">photos</a>
{% endif %}
</h4>
{% for item in trip.accommodation %}
{% set country = get_country(item.country) if item.country else None %}
{% set nights = (item.to.date() - item.from.date()).days %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
{% if item.operator %}{{ item.operator }}: {% endif %}
<a href="{{ item.url }}">{{ item.name }}</a>
<small class="text-muted">
{{ display_date_no_year(item.from) }} to {{ display_date_no_year(item.to) }}
({% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %})
</small>
</h5>
<p class="card-text">
<strong>Address:</strong> {{ item.address }}
<strong>Location:</strong> {{ item.location }}
{% if country %}
{{ country.flag if trip.show_flags }}
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% for e in day_elements %}
{% if e.element_type == "conference" %}
{% set item = e.detail %}
{% set country = get_country(item.country) if item.country else None %}
<div class="trip-conference-card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.name }}</a>
<small class="text-muted">
{{ display_conf_date_no_year(item.attend_start if item.attend_start else item.start) }} to {{ display_conf_date_no_year(item.attend_end if item.attend_end else item.end) }}
{% if item.attend_start or item.attend_end %}
(full conference: {{ display_date_no_year(item.start) }} to {{ display_date_no_year(item.end) }})
{% endif %}
</small>
</h5>
<p class="card-text">
<strong>Topic:</strong> {{ item.topic }}
<strong>Venue:</strong> {{ item.venue }}
<strong>Location:</strong> {{ item.location }}
{% if country %}
{{ country.flag if trip.show_flags }}
{% elif item.online %}
💻 Online
{% else %}
<span class="text-bg-danger p-2">country code <strong>{{ item.country }}</strong> not found</span>
{% endif %}
{% if item.free %}
<span class="badge bg-success text-nowrap">free to attend</span>
{% elif item.price and item.currency %}
<span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% elif e.element_type == "check-in" %}
{% set item = e.detail %}
{% set country = get_country(item.country) if item.country else None %}
{% set nights = (item.to.date() - item.from.date()).days %}
<div class="trip-accommodation-card my-1">
<div class="card-body">
<h5 class="card-title">
{{ e.get_emoji() }}
<a href="{{ item.url }}">{{ item.name }}</a>
{% if item.operator and item.operator != item.name %}<small class="text-muted fw-normal">{{ item.operator }}</small>{% endif %}
<small class="text-muted">({% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %})</small>
</h5>
<p class="card-text">
{{ item.location }}
{% if country %}
{{ country.flag if trip.show_flags }}
{% else %}
<span class="text-bg-danger p-2">country code <strong>{{ item.country }}</strong> not found</span>
{% endif %}
{% if item.address %} · {{ item.address }}{% endif %}
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap ms-1">{{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% elif e.element_type == "check-out" %}
{% set item = e.detail %}
<div class="trip-checkout">
{{ e.get_emoji() }} Check out: <a href="{{ item.url }}">{{ item.name }}</a>
{% if item.operator and item.operator != item.name %}<span class="text-muted small">{{ item.operator }}</span>{% endif %}
</div>
{% elif e.element_type == "flight" %}
{% set item = e.detail %}
{% set full_flight_number = item.airline_code + item.flight_number %}
{% set radarbox_url = "https://www.radarbox.com/data/flights/" + full_flight_number %}
{% set is_overnight = item.arrive and item.depart.date() != item.arrive.date() %}
<div class="trip-transport-card my-1">
<div class="card-body">
<h5 class="card-title">
✈️
{{ item.from_airport.name }} ({{ item.from_airport.iata }})
{{ item.to_airport.name }} ({{ item.to_airport.iata }})
</h5>
<div class="card-text">
<div>
<span>{{ item.airline_name }}</span>
<span class="text-muted small">{{ full_flight_number }}</span>
· {{ item.depart.strftime("%H:%M") }}
{% if item.arrive %}
→ {{ item.arrive.strftime("%H:%M") }}{% if is_overnight %} <span class="text-muted small">+1 day</span>{% endif %}
<span class="text-muted">🕒{{ trip_duration(item.depart, item.arrive) }}</span>
{% endif %}
{% if item.distance %}
<span class="text-muted">🌍 {{ "{:,.0f} km".format(item.distance) }}</span>
{% endif %}
{% if item.co2_kg is defined and item.co2_kg is not none %}
<span class="text-muted">CO₂ {{ "{:,.1f}".format(item.co2_kg) }} kg</span>
{% endif %}
</div>
<div class="small mt-1">
<a href="https://www.flightradar24.com/data/flights/{{ item.airline_detail.iata | lower }}{{ item.flight_number }}">flightradar24</a>
· <a href="https://uk.flightaware.com/live/flight/{{ full_flight_number }}">FlightAware</a>
· <a href="{{ radarbox_url }}">radarbox</a>
</div>
</div>
</div>
</div>
{% elif e.element_type == "train" %}
{% set item = e.detail %}
{% set is_overnight = item.depart.date() != item.arrive.date() %}
<div class="trip-transport-card my-1">
<div class="card-body">
<h5 class="card-title">
{% if is_overnight %}🌙{% else %}🚆{% endif %}
{{ item.from }} → {{ item.to }}
{% if item.operator %}<small class="text-muted fw-normal">{{ item.operator }}</small>{% endif %}
{% if is_overnight %}<span class="badge bg-secondary text-nowrap ms-1">Night train</span>{% endif %}
</h5>
<p class="card-text">
{{ item.depart.strftime("%H:%M") }}
→ {{ item.arrive.strftime("%H:%M") }}{% if is_overnight %} <span class="text-muted small">+1 day</span>{% endif %}
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
<span class="text-muted">🕒{{ trip_duration(item.depart, item.arrive) }}</span>
{% if item.distance %}
<span class="text-muted">🛤️ {{ "{:,.0f} km".format(item.distance) }}</span>
{% endif %}
{% if item.co2_kg is defined and item.co2_kg is not none %}
<span class="text-muted">CO₂ {{ "{:,.1f}".format(item.co2_kg) }} kg</span>
{% endif %}
{% if item.coach %}
<span class="text-nowrap">🛏️ Coach {{ item.coach }}{% if item.seat %}, Seat {% if item.seat is iterable and item.seat is not string %}{{ item.seat | join(" & ") }}{% else %}{{ item.seat }}{% endif %}{% endif %}</span>
{% endif %}
</p>
</div>
</div>
{% elif e.element_type in ("coach", "bus") %}
{% set item = e.detail %}
<div class="trip-transport-card my-1">
<div class="card-body">
<h5 class="card-title">
🚌 {{ item.from }} → {{ item.to }}
{% if item.operator %}<small class="text-muted fw-normal">{{ item.operator }}</small>{% endif %}
</h5>
<p class="card-text">
{{ item.depart.strftime("%H:%M") }} → {{ item.arrive.strftime("%H:%M") }}
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
<span class="text-muted">🕒{{ trip_duration(item.depart, item.arrive) }}</span>
{% if item.distance %}
<span class="text-muted">🛤️ {{ "{:,.0f} km".format(item.distance) }}</span>
{% endif %}
{% if item.co2_kg is defined and item.co2_kg is not none %}
<span class="text-muted">CO₂ {{ "{:,.1f}".format(item.co2_kg) }} kg</span>
{% endif %}
</p>
</div>
</div>
{% elif e.element_type == "ferry" %}
{% set item = e.detail %}
<div class="trip-transport-card my-1">
<div class="card-body">
<h5 class="card-title">
⛴️ {{ item.from }} → {{ item.to }}
<small class="text-muted fw-normal">{{ item.operator }}{% if item.ferry %} · {{ item.ferry }}{% endif %}</small>
</h5>
<p class="card-text">
<div>
{{ item.depart.strftime("%H:%M") }} → {{ item.arrive.strftime("%H:%M") }}
<span class="text-muted">🕒{{ trip_duration(item.depart, item.arrive) }}</span>
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
{% if item.co2_kg is defined and item.co2_kg is not none %}
<span class="text-muted">CO₂ {{ "{:,.1f}".format(item.co2_kg) }} kg</span>
{% endif %}
</div>
{% if item.vehicle %}
<div>🚗 Vehicle: {{ item.vehicle.type }} {% if g.user.is_authenticated %}({{ item.vehicle.registration }}) {% endif %}
{% if item.vehicle.extras %} - Extras: {{ item.vehicle.extras | join(", ") }}{% endif %}
</div>
{% endif %}
{% if g.user.is_authenticated %}
<div>
{% if item.booking_reference %}<strong>Booking reference:</strong> {{ item.booking_reference }}{% endif %}
{% if item.price and item.currency %}<span class="badge bg-info text-nowrap">Price: {{ item.price }} {{ item.currency }}</span>{% endif %}
</div>
{% endif %}
</p>
</div>
</div>
{% endif %}
{% endfor %}
{% endfor %}
{% if trip.flight_bookings %}
<h3>Flight bookings</h3>
<h3 class="trip-section-h">Flight bookings</h3>
{% for item in trip.flight_bookings %}
<div>
{{ item.flights | map(attribute="airline_name") | unique | join(" + ") }}
@ -257,9 +423,11 @@
{% endfor %}
{% endif %}
{% if trip.events %}
<h4 class="trip-section-h">Events</h4>
{% for item in trip.events %}
{% set country = get_country(item.country) if item.country else None %}
<div class="card my-1">
<div class="trip-event-card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.title }}</a>
@ -271,9 +439,7 @@
{% if country %}
{{ country.flag if trip.show_flags }}
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
<span class="text-bg-danger p-2">country code <strong>{{ item.country }}</strong> not found</span>
{% endif %}
{% if g.user.is_authenticated and item.price and item.currency %}
| <span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
@ -282,150 +448,10 @@
</div>
</div>
{% endfor %}
{% for item in trip.travel %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
{% if item.type == "flight" %}
✈️
{{ item.from_airport.name }} ({{ item.from_airport.iata}})
{{ item.to_airport.name }} ({{item.to_airport.iata}})
{% elif item.type == "train" %}
🚆
{{ item.from }}
{{ item.to }}
{% elif item.type == "coach" or item.type == "bus" %}
🚌
{{ item.from }}
{{ item.to }}
{% elif item.type == "ferry" %}
⛴️
{{ item.from }}
{{ item.to }}
{% endif %}
</h5>
<p class="card-text">
{% if item.type == "flight" %}
<div>
<span>{{ item.airline_name }} ({{ item.airline }})</span>
{{ display_datetime(item.depart) }}
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M %z") }}
<span>🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</span>
{% endif %}
<span>{{ item.airline_code }}{{ item.flight_number }}</span>
{% if item.distance %}
<span>
🌍distance:
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
</span>
{% endif %}
</div>
{% elif item.type == "train" %}
<div>
{{ display_datetime(item.depart) }}
{{ item.arrive.strftime("%H:%M %z") }}
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
<span>🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</span>
{% if item.distance %}
<span>
🛤️
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
</span>
{% endif %}
</div>
{% elif item.type == "coach" or item.type == "bus" %}
<div>
{{ display_datetime(item.depart) }}
{{ item.arrive.strftime("%H:%M %z") }}
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
<span>🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</span>
{% if item.distance %}
<span>
🛤️
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
</span>
{% endif %}
</div>
{% elif item.type == "ferry" %}
<div>
<span>{{ item.operator }} - {{ item.ferry }}</span>
{{ display_datetime(item.depart) }}
{{ item.arrive.strftime("%H:%M %z") }}
<span>🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</span>
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
</div>
{% if item.vehicle %}
<div>
🚗 Vehicle: {{ item.vehicle.type }} {% if g.user.is_authenticated %}({{ item.vehicle.registration }}) {% endif %}
{% if item.vehicle.extras %}
- Extras: {{ item.vehicle.extras | join(", ") }}
{% endif %}
</div>
{% endif %}
{% if g.user.is_authenticated %}
<div>
{% if item.booking_reference %}
<strong>Booking reference:</strong> {{ item.booking_reference }}
{% endif %}
{% if item.price and item.currency %}
<span class="badge bg-info text-nowrap">Price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</div>
{% endif %}
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% if trip_weather %}
<div class="mt-3">
<h4>Weather forecast</h4>
{% set ns = namespace(has_rows=false) %}
<table class="table table-hover w-auto">
{% for day, elements in trip.elements_grouped_by_day() %}
{% set weather = trip_weather.get(day.isoformat()) %}
{% if weather %}
{% set ns.has_rows = true %}
<tr>
<td class="text-end text-nowrap">{{ display_date(day) }}</td>
<td><img src="https://openweathermap.org/img/wn/{{ weather.icon }}.png" alt="{{ weather.status }}" title="{{ weather.detailed_status }}" width="25" height="25"></td>
<td>{{ weather.temp_min }}{{ weather.temp_max }}°C</td>
<td>{{ weather.detailed_status }}</td>
</tr>
{% endif %}
{% endfor %}
</table>
{% if not ns.has_rows %}
<p class="text-muted">Forecast not yet available (available up to 8 days ahead).</p>
{% endif %}
</div>
{% endif %}
<div class="mt-3">
<h4>Holidays</h4>
<h4 class="trip-section-h">Holidays</h4>
{% if holidays %}
<table class="table table-hover w-auto">
{% for item in holidays %}
@ -447,7 +473,7 @@
</div>
<div class="mt-3">
<h4>UK school holidays (Bristol)</h4>
<h4 class="trip-section-h">UK school holidays (Bristol)</h4>
{% if school_holidays %}
<table class="table table-hover w-auto">
{% for item in school_holidays %}

View file

@ -0,0 +1,188 @@
"""Tests for trip list template rendering.
Covers edge cases found in real data that caused UndefinedError / AttributeError:
1. Flight where 'arrive' key is entirely absent (older data format).
Jinja2 returns Undefined for missing dict keys, which is truthy, so a
plain truthiness check on item.arrive would pass and then .date() would
raise UndefinedError.
2. Train where depart/arrive are date objects (not datetime).
datetime.date has no .date() method, so item.depart.date() raises
AttributeError (surfaced as UndefinedError inside Jinja2).
"""
from datetime import date, datetime, timezone
import flask
import pytest
import web_view
from agenda.types import TripElement
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeTrip:
"""Minimal trip stub sufficient for render_trip_element."""
show_flags = False
def _render(element: TripElement) -> str:
"""Render a single TripElement via the render_trip_element macro."""
with web_view.app.app_context():
with web_view.app.test_request_context():
flask.g.user = web_view.User(is_authenticated=False)
macros = web_view.app.jinja_env.get_template("macros.html")
return macros.module.render_trip_element(element, _FakeTrip())
def _flight(detail: dict) -> TripElement:
return TripElement(
start_time=detail["depart"],
title="test flight",
element_type="flight",
detail=detail,
start_loc="Origin",
end_loc="Dest",
)
def _train(detail: dict) -> TripElement:
return TripElement(
start_time=detail["depart"],
title="test train",
element_type="train",
detail=detail,
start_loc=detail["from"],
end_loc=detail["to"],
)
# ---------------------------------------------------------------------------
# Unit tests — specific edge cases
# ---------------------------------------------------------------------------
def test_flight_missing_arrive_key() -> None:
"""Flight with no 'arrive' key must not raise UndefinedError.
Older trips store flights without an arrival time. Jinja2 returns
Undefined (truthy!) for absent dict keys, so a bare truthiness check
was insufficient only 'is defined' correctly guards against it.
"""
element = _flight(
{
"depart": datetime(2023, 7, 1, 13, 20, tzinfo=timezone.utc),
# 'arrive' key intentionally absent
"from": "TIA",
"to": "LHR",
"flight_number": "BA123",
"airline_code": "BA",
"airline_name": "British Airways",
"distance": 2000.0,
}
)
result = _render(element)
assert "Origin" in result
assert "Dest" in result
assert "13:05" not in result # arrive time should not appear
def test_flight_arrive_is_none() -> None:
"""Flight where 'arrive' key exists but value is None must render cleanly."""
element = _flight(
{
"depart": datetime(2023, 7, 1, 13, 20, tzinfo=timezone.utc),
"arrive": None,
"from": "TIA",
"to": "LHR",
"flight_number": "BA123",
"airline_code": "BA",
"airline_name": "British Airways",
"distance": 2000.0,
}
)
result = _render(element)
assert "Origin" in result
def test_train_date_only_fields() -> None:
"""Train with date (not datetime) depart/arrive must not raise AttributeError.
Some older train entries record only the travel date with no time.
datetime.date has no .date() method, so item.depart.date() would fail.
"""
element = _train(
{
"depart": date(2024, 10, 22),
"arrive": date(2024, 10, 22),
"from": "Llandrindod",
"to": "Llandovery",
"operator": "Transport for Wales",
}
)
result = _render(element)
assert "Llandrindod" in result
assert "Llandovery" in result
def test_train_overnight_datetime() -> None:
"""Overnight train (arrive on next day) shows night moon emoji and +1 day."""
tz = timezone.utc
element = _train(
{
"depart": datetime(2026, 1, 19, 19, 6, tzinfo=tz),
"arrive": datetime(2026, 1, 20, 10, 13, tzinfo=tz),
"from": "Brussels Midi",
"to": "Vienna Hbf",
"operator": "ÖBB",
}
)
result = _render(element)
assert "🌙" in result
assert "+1 day" in result
def test_train_seat_as_integer() -> None:
"""Train with seat as an integer (not a list) must render without error."""
tz = timezone.utc
element = _train(
{
"depart": datetime(2025, 5, 26, 13, 5, tzinfo=tz),
"arrive": datetime(2025, 5, 26, 13, 29, tzinfo=tz),
"from": "Wánchaq",
"to": "Puno",
"coach": "C",
"seat": 1,
}
)
result = _render(element)
assert "Wánchaq" in result
# ---------------------------------------------------------------------------
# Integration tests — render the real trip list pages end-to-end
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
"""Flask test client."""
web_view.app.config["TESTING"] = True
with web_view.app.test_client() as c:
yield c
def test_past_trips_page_renders(client) -> None:
"""Past trips list page renders without any template errors (HTTP 200)."""
response = client.get("/trip/past")
assert response.status_code == 200
def test_future_trips_page_renders(client) -> None:
"""Future trips list page renders without any template errors (HTTP 200)."""
response = client.get("/trip/future")
assert response.status_code == 200