From 574b4feb1f15ca27b1bd6cfad0d11e119cce0766 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Tue, 3 Mar 2026 00:54:42 +0000 Subject: [PATCH] trip: redesign itinerary display and add trip list macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- static/css/trips.css | 381 +++++++++++++++++++++++++ templates/macros.html | 155 +++++------ templates/trip/list.html | 55 ++-- templates/trip_page.html | 494 +++++++++++++++++---------------- tests/test_trip_list_render.py | 188 +++++++++++++ 5 files changed, 935 insertions(+), 338 deletions(-) create mode 100644 static/css/trips.css create mode 100644 tests/test_trip_list_render.py diff --git a/static/css/trips.css b/static/css/trips.css new file mode 100644 index 0000000..7e1cbbc --- /dev/null +++ b/static/css/trips.css @@ -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; +} diff --git a/templates/macros.html b/templates/macros.html index 35d64bd..71363b5 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -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 %} -
+
{{ item.name }} @@ -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 %} +
+ {{ e.get_emoji() }} {{ item.name }} + {% if item.operator and item.operator != item.name %}{{ item.operator }}{% endif %} + ({% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %}) +
+ {% elif e.element_type == "check-out" %} +
+ {{ e.get_emoji() }} Check out: {{ item.name }} + {% if item.operator and item.operator != item.name %}{{ item.operator }}{% endif %} +
+ {% 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 %} +
+ {% 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 %} +1 day{% endif %}{% endif %} + {% endif %} + {% if dur_mins %} + {%- set h = dur_mins // 60 %}{%- set m = dur_mins % 60 %} + πŸ•’{% if h %}{{ h }}h {% endif %}{% if m %}{{ m }}m{% endif %} + {% endif %} + {% if e.element_type == "flight" %} + Β· {{ item.airline_name }} {{ item.airline_code }}{{ item.flight_number }} + {% elif item.operator %} + Β· {{ item.operator }} + {% endif %} + {% if item.distance %} + Β· {{ "{:,.0f} km".format(item.distance) }} + {% endif %} + {% if item.co2_kg is defined and item.co2_kg is not none %} + COβ‚‚ {{ "{:,.1f}".format(item.co2_kg) }} kg + {% endif %} +
+ {% 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 %} -
-

+ {% set trip_end = end or trip.start %} + {% set is_current = trip.start <= today and trip_end >= today %} +
+

{{ trip_link(trip) }} - ({{ display_date(trip.start) }})

+ ({{ display_date(trip.start) }})

{% set school_holidays = trip_school_holiday_map.get(trip.start.isoformat(), []) if trip_school_holiday_map is defined else [] %} {% if school_holidays %} -
+
UK school holiday {% for item in school_holidays %} {{ item.title }} ({{ display_date_no_year(item.as_date) }} to {{ display_date_no_year(item.end_as_date) }}) {% endfor %}
{% endif %} -
    +
      {% for c in trip.countries %} -
    • - {{ c.name }} - {{ c.flag }} -
    • +
    • {{ c.flag }} {{ c.name }}
    • {% endfor %}
    {% if end %} -
    Dates: {{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }} +
    Dates: {{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }} {% if g.user.is_authenticated and trip.start <= today %} photos {% endif %} @@ -429,36 +476,25 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower {% else %}
    Start: {{ display_date_no_year(trip.start) }} (end date missing)
    {% endif %} +
    {% if total_distance %} -
    - Total distance: - {{ format_distance(total_distance) }} -
    + {{ format_distance(total_distance) }} {% endif %} - - {% if distances_by_transport_type %} {% for transport_type, distance in distances_by_transport_type %} -
    - {{ transport_type | title }} - distance: {{format_distance(distance) }} -
    + {{ transport_type | title }}: {{format_distance(distance) }} {% endfor %} {% endif %} - {% if total_co2_kg %} -
    Total COβ‚‚: {{ "{:,.1f}".format(total_co2_kg) }} kg
    + COβ‚‚ {{ "{:,.1f}".format(total_co2_kg) }} kg {% endif %} - {% set co2_by_transport = trip.co2_by_transport_type() %} {% if co2_by_transport %} {% for transport_type, co2_kg in co2_by_transport %} -
    - {{ transport_type | title }} - COβ‚‚: {{ "{:,.1f}".format(co2_kg) }} kg -
    + {{ transport_type | title }} COβ‚‚ {{ "{:,.1f}".format(co2_kg) }} kg {% endfor %} {% endif %} +
    {% if trip.schengen_compliance %}
    @@ -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()) %} -

    {{ display_date_no_year(day) }} +

    {{ display_date_no_year(day) }} {% if weather %} - - {{ weather.status }} - {{ weather.temp_min }}–{{ weather.temp_max }}Β°C {{ weather.detailed_status }} - - {% endif %} - {% if g.user.is_authenticated and day <= today %} - - photos + + {{ weather.status }} + {{ weather.temp_min }}–{{ weather.temp_max }}Β°C + {{ weather.detailed_status }} {% endif %} -

    - {% 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) %} -
    - {{ e.get_emoji() }} {{ e.title }} {{ flag(trip, c.flag) }} - ({{ accommodation_label[e.element_type] }} {{ display_time(e.start_time) }}) -
    - {% else %} -
    - {{ e.get_emoji() }} - {{ display_time(e.start_time) }} - – - {{ e.start_loc }} {{ flag(trip, e.start_country.flag) }} - β†’ - {{ display_time(e.end_time) }} - – - {{ 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 %} - airline: {{ flight.airline_name }} - flight number: {{ full_flight_number }} - {% if flight.duration %} - duration: {{ flight.duration }} - {% endif %} - {#
    {{ flight | pprint }}
    #} - {% if flight.co2_kg is defined and flight.co2_kg is not none %} - COβ‚‚: {{ "{:,.1f}".format(flight.co2_kg) }} kg - {% endif %} - {% endif %} - {% if e.detail.distance %} - distance: {{ format_distance(e.detail.distance) }} - {% endif %} - {% if e.element_type == "flight" %} - flightradar24 - | FlightAware - | radarbox - {% endif %} -
    + {% if g.user.is_authenticated and day <= today %} + photos {% endif %} - {% endfor %} +
+ {% for e in elements %} + {{ render_trip_element(e, trip) }} + {% endfor %} {% endfor %}
diff --git a/templates/trip/list.html b/templates/trip/list.html index 0eaf27f..5be5b4d 100644 --- a/templates/trip/list.html +++ b/templates/trip/list.html @@ -7,6 +7,7 @@ {% block style %} + @@ -85,8 +103,8 @@
- {{ next_and_previous() }} -

{{ trip.title }}

+
{{ next_and_previous() }}
+

{{ trip.title }}

{% if end %} {{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }} @@ -97,8 +115,11 @@

- {#
Countries: {{ trip.countries_str }}
#} -
Locations: {{ trip.locations_str }}
+
    + {% for location, country in trip.locations() %} +
  • {{ country.flag if trip.show_flags }} {{ location }}
  • + {% endfor %} +
{% if destination_times %}
Destination time zones @@ -123,31 +144,24 @@
{% endif %} +
{% if total_distance %} -
Total distance: - {{ "{:,.0f} km / {:,.0f} miles".format(total_distance, total_distance / 1.60934) }} -
+ {{ "{:,.0f} km / {:,.0f} mi".format(total_distance, total_distance / 1.60934) }} {% endif %} - {% if distances_by_transport_type %} {% for transport_type, distance in distances_by_transport_type %} -
{{ transport_type | title }} distance: - {{ "{:,.0f} km / {:,.0f} miles".format(distance, distance / 1.60934) }} -
+ {{ transport_type | title }}: {{ "{:,.0f} km".format(distance) }} {% endfor %} {% endif %} - {% if total_co2_kg %} -
Total COβ‚‚: {{ "{:,.1f}".format(total_co2_kg) }} kg
+ COβ‚‚ {{ "{:,.1f}".format(total_co2_kg) }} kg {% endif %} - {% if co2_by_transport_type %} {% for transport_type, co2_kg in co2_by_transport_type %} -
{{ transport_type | title }} COβ‚‚: - {{ "{:,.1f}".format(co2_kg) }} kg -
+ {{ transport_type | title }} COβ‚‚ {{ "{:,.1f}".format(co2_kg) }} kg {% endfor %} {% endif %} +
{% set delta = human_readable_delta(trip.start) %} @@ -175,75 +189,227 @@ {% endif %}
- {% for item in trip.conferences %} - {% set country = get_country(item.country) if item.country else None %} -
-
-
- {{ item.name }} - - {{ 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 %} - -
-

- Topic: {{ item.topic }} - Venue: {{ item.venue }} - Location: {{ item.location }} - {% if country %} - {{ country.flag if trip.show_flags }} - {% elif item.online %} - πŸ’» Online - {% else %} - - country code {{ item.country }} not found - - {% endif %} - {% if item.free %} - free to attend - {% elif item.price and item.currency %} - price: {{ item.price }} {{ item.currency }} - {% endif %} -

-
-
- {% 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 %} +

+ {{ display_date_no_year(day) }} + {% if weather %} + + {{ weather.status }} + {{ weather.temp_min }}–{{ weather.temp_max }}Β°C + {{ weather.detailed_status }} + + {% endif %} + {% if g.user.is_authenticated and day <= today %} + photos + {% endif %} +

- {% 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 %} -
-
-
- {% if item.operator %}{{ item.operator }}: {% endif %} - {{ item.name }} - - {{ display_date_no_year(item.from) }} to {{ display_date_no_year(item.to) }} - ({% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %}) - -
-

- Address: {{ item.address }} - Location: {{ item.location }} - {% if country %} - {{ country.flag if trip.show_flags }} - {% else %} - - country code {{ item.country }} not found - - {% endif %} - {% if g.user.is_authenticated and item.price and item.currency %} - price: {{ item.price }} {{ item.currency }} - {% endif %} -

-
-
+ {% 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 %} +
+
+
+ {{ item.name }} + + {{ 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 %} + +
+

+ Topic: {{ item.topic }} + Venue: {{ item.venue }} + Location: {{ item.location }} + {% if country %} + {{ country.flag if trip.show_flags }} + {% elif item.online %} + πŸ’» Online + {% else %} + country code {{ item.country }} not found + {% endif %} + {% if item.free %} + free to attend + {% elif item.price and item.currency %} + price: {{ item.price }} {{ item.currency }} + {% endif %} +

+
+
+ + {% 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 %} +
+
+
+ {{ e.get_emoji() }} + {{ item.name }} + {% if item.operator and item.operator != item.name %}{{ item.operator }}{% endif %} + ({% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %}) +
+

+ {{ item.location }} + {% if country %} + {{ country.flag if trip.show_flags }} + {% else %} + country code {{ item.country }} not found + {% endif %} + {% if item.address %} Β· {{ item.address }}{% endif %} + {% if g.user.is_authenticated and item.price and item.currency %} + {{ item.price }} {{ item.currency }} + {% endif %} +

+
+
+ + {% elif e.element_type == "check-out" %} + {% set item = e.detail %} +
+ {{ e.get_emoji() }} Check out: {{ item.name }} + {% if item.operator and item.operator != item.name %}{{ item.operator }}{% endif %} +
+ + {% 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() %} +
+
+
+ ✈️ + {{ item.from_airport.name }} ({{ item.from_airport.iata }}) + β†’ + {{ item.to_airport.name }} ({{ item.to_airport.iata }}) +
+
+
+ {{ item.airline_name }} + {{ full_flight_number }} + Β· {{ item.depart.strftime("%H:%M") }} + {% if item.arrive %} + β†’ {{ item.arrive.strftime("%H:%M") }}{% if is_overnight %} +1 day{% endif %} + πŸ•’{{ trip_duration(item.depart, item.arrive) }} + {% endif %} + {% if item.distance %} + 🌍 {{ "{:,.0f} km".format(item.distance) }} + {% endif %} + {% if item.co2_kg is defined and item.co2_kg is not none %} + COβ‚‚ {{ "{:,.1f}".format(item.co2_kg) }} kg + {% endif %} +
+
+ flightradar24 + Β· FlightAware + Β· radarbox +
+
+
+
+ + {% elif e.element_type == "train" %} + {% set item = e.detail %} + {% set is_overnight = item.depart.date() != item.arrive.date() %} +
+
+
+ {% if is_overnight %}πŸŒ™{% else %}πŸš†{% endif %} + {{ item.from }} β†’ {{ item.to }} + {% if item.operator %}{{ item.operator }}{% endif %} + {% if is_overnight %}Night train{% endif %} +
+

+ {{ item.depart.strftime("%H:%M") }} + β†’ {{ item.arrive.strftime("%H:%M") }}{% if is_overnight %} +1 day{% endif %} + {% if item.class %} + {{ item.class }} + {% endif %} + πŸ•’{{ trip_duration(item.depart, item.arrive) }} + {% if item.distance %} + πŸ›€οΈ {{ "{:,.0f} km".format(item.distance) }} + {% endif %} + {% if item.co2_kg is defined and item.co2_kg is not none %} + COβ‚‚ {{ "{:,.1f}".format(item.co2_kg) }} kg + {% endif %} + {% if item.coach %} + πŸ›οΈ 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 %} + {% endif %} +

+
+
+ + {% elif e.element_type in ("coach", "bus") %} + {% set item = e.detail %} +
+
+
+ 🚌 {{ item.from }} β†’ {{ item.to }} + {% if item.operator %}{{ item.operator }}{% endif %} +
+

+ {{ item.depart.strftime("%H:%M") }} β†’ {{ item.arrive.strftime("%H:%M") }} + {% if item.class %} + {{ item.class }} + {% endif %} + πŸ•’{{ trip_duration(item.depart, item.arrive) }} + {% if item.distance %} + πŸ›€οΈ {{ "{:,.0f} km".format(item.distance) }} + {% endif %} + {% if item.co2_kg is defined and item.co2_kg is not none %} + COβ‚‚ {{ "{:,.1f}".format(item.co2_kg) }} kg + {% endif %} +

+
+
+ + {% elif e.element_type == "ferry" %} + {% set item = e.detail %} +
+
+
+ ⛴️ {{ item.from }} β†’ {{ item.to }} + {{ item.operator }}{% if item.ferry %} Β· {{ item.ferry }}{% endif %} +
+

+

+ {{ item.depart.strftime("%H:%M") }} β†’ {{ item.arrive.strftime("%H:%M") }} + πŸ•’{{ trip_duration(item.depart, item.arrive) }} + {% if item.class %} + {{ item.class }} + {% endif %} + {% if item.co2_kg is defined and item.co2_kg is not none %} + COβ‚‚ {{ "{:,.1f}".format(item.co2_kg) }} kg + {% endif %} +
+ {% if item.vehicle %} +
πŸš— Vehicle: {{ item.vehicle.type }} {% if g.user.is_authenticated %}({{ item.vehicle.registration }}) {% endif %} + {% if item.vehicle.extras %} - Extras: {{ item.vehicle.extras | join(", ") }}{% endif %} +
+ {% endif %} + {% if g.user.is_authenticated %} +
+ {% if item.booking_reference %}Booking reference: {{ item.booking_reference }}{% endif %} + {% if item.price and item.currency %}Price: {{ item.price }} {{ item.currency }}{% endif %} +
+ {% endif %} +

+
+
+ + {% endif %} + {% endfor %} {% endfor %} {% if trip.flight_bookings %} -

Flight bookings

+

Flight bookings

{% for item in trip.flight_bookings %}
{{ item.flights | map(attribute="airline_name") | unique | join(" + ") }} @@ -257,9 +423,11 @@ {% endfor %} {% endif %} + {% if trip.events %} +

Events

{% for item in trip.events %} {% set country = get_country(item.country) if item.country else None %} -
+
{{ item.title }} @@ -271,9 +439,7 @@ {% if country %} {{ country.flag if trip.show_flags }} {% else %} - - country code {{ item.country }} not found - + country code {{ item.country }} not found {% endif %} {% if g.user.is_authenticated and item.price and item.currency %} | price: {{ item.price }} {{ item.currency }} @@ -282,150 +448,10 @@
{% endfor %} - - {% for item in trip.travel %} -
-
-
- {% 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 %} -
-

- {% if item.type == "flight" %} -

- {{ item.airline_name }} ({{ item.airline }}) - ✨ - {{ display_datetime(item.depart) }} - {% if item.arrive %} - β†’ - {{ item.arrive.strftime("%H:%M %z") }} - πŸ•’{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins - {% endif %} - ✨ - {{ item.airline_code }}{{ item.flight_number }} - - {% if item.distance %} - - 🌍distance: - {{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }} - - {% endif %} - - -
- {% elif item.type == "train" %} -
- {{ display_datetime(item.depart) }} - β†’ - {{ item.arrive.strftime("%H:%M %z") }} - {% if item.class %} - {{ item.class }} - {% endif %} - πŸ•’{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins - {% if item.distance %} - - πŸ›€οΈ - {{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }} - - {% endif %} -
- {% elif item.type == "coach" or item.type == "bus" %} -
- {{ display_datetime(item.depart) }} - β†’ - {{ item.arrive.strftime("%H:%M %z") }} - {% if item.class %} - {{ item.class }} - {% endif %} - πŸ•’{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins - {% if item.distance %} - - πŸ›€οΈ - {{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }} - - {% endif %} -
- {% elif item.type == "ferry" %} -
- {{ item.operator }} - {{ item.ferry }} - ✨ - {{ display_datetime(item.depart) }} - β†’ - {{ item.arrive.strftime("%H:%M %z") }} - πŸ•’{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins - {% if item.class %} - {{ item.class }} - {% endif %} -
- {% if item.vehicle %} -
- πŸš— Vehicle: {{ item.vehicle.type }} {% if g.user.is_authenticated %}({{ item.vehicle.registration }}) {% endif %} - {% if item.vehicle.extras %} - - Extras: {{ item.vehicle.extras | join(", ") }} - {% endif %} -
- {% endif %} - {% if g.user.is_authenticated %} -
- {% if item.booking_reference %} - Booking reference: {{ item.booking_reference }} - {% endif %} - {% if item.price and item.currency %} - Price: {{ item.price }} {{ item.currency }} - {% endif %} -
- {% endif %} - {% endif %} -

-
-
- {% endfor %} - - {% if trip_weather %} -
-

Weather forecast

- {% set ns = namespace(has_rows=false) %} - - {% for day, elements in trip.elements_grouped_by_day() %} - {% set weather = trip_weather.get(day.isoformat()) %} - {% if weather %} - {% set ns.has_rows = true %} - - - - - - - {% endif %} - {% endfor %} -
{{ display_date(day) }}{{ weather.status }}{{ weather.temp_min }}–{{ weather.temp_max }}Β°C{{ weather.detailed_status }}
- {% if not ns.has_rows %} -

Forecast not yet available (available up to 8 days ahead).

- {% endif %} -
{% endif %}
-

Holidays

+

Holidays

{% if holidays %} {% for item in holidays %} @@ -447,7 +473,7 @@
-

UK school holidays (Bristol)

+

UK school holidays (Bristol)

{% if school_holidays %}
{% for item in school_holidays %} diff --git a/tests/test_trip_list_render.py b/tests/test_trip_list_render.py new file mode 100644 index 0000000..5ad1b03 --- /dev/null +++ b/tests/test_trip_list_render.py @@ -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