agenda/templates/trip_page.html
Edward Betts 574b4feb1f 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>
2026-03-03 09:15:51 +00:00

518 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}{{ trip.title }} ({{ display_date(trip.start) }}) - Edward Betts{% endblock %}
{% from "macros.html" import trip_link, display_datetime, display_date_no_year, display_date, display_conf_date_no_year, conference_row, accommodation_row, flight_row, train_row, ferry_row, coach_row, bus_row with context %}
{% 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 %}
previous: {{ trip_link(prev_trip) }} ({{ (trip.start - prev_trip.end).days }} days)
{% endif %}
{% if next_trip %}
next: {{ trip_link(next_trip) }} ({{ (next_trip.start - trip.end).days }} days)
{% endif %}
</p>
{% endmacro %}
{% block style %}
{% 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 %}
{% set travel_column_count = 9 %}
<style>
.conferences {
display: grid;
grid-template-columns: repeat({{ conference_column_count }}, auto); /* 7 columns for each piece of information */
gap: 10px;
justify-content: start;
}
.accommodation {
display: grid;
grid-template-columns: repeat({{ accommodation_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.travel {
display: grid;
grid-template-columns: repeat({{ travel_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.grid-item {
/* Additional styling for grid items can go here */
}
.half-map {
height: 90vh;
}
.full-window-map {
position: fixed;
top: 56px;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
#toggleMapSize {
position: fixed;
top: 66px;
right: 10px;
z-index: 10000;
}
@media (max-width: 767.98px) {
#toggleMapSize {
display: none;
}
.half-map {
height: 50vh;
}
}
</style>
{% endblock %}
{% set end = trip.end %}
{% set total_distance = trip.total_distance() %}
{% set distances_by_transport_type = trip.distances_by_transport_type() %}
{% set total_co2_kg = trip.total_co2_kg() %}
{% set co2_by_transport_type = trip.co2_by_transport_type() %}
{% block content %}
<div class="row">
<div class="col-md-6 col-sm-12">
<div class="m-3">
<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) }}
({{ (end - trip.start).days }} nights)
{% else %}
{{ display_date_no_year(trip.start) }} (end date missing)
{% endif %}
</p>
<div class="mb-3">
<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>
<table class="table table-sm table-hover w-auto mb-0">
<thead>
<tr>
<th>Destination</th>
<th>Timezone</th>
<th>Difference from UK</th>
</tr>
</thead>
<tbody>
{% for item in destination_times %}
<tr>
<td>{{ item.destination_label }}</td>
<td>{{ item.timezone or "Unknown" }}</td>
<td class="destination-offset">{{ item.offset_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="trip-stats">
{% if total_distance %}
<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 %}
<span class="trip-stat">{{ transport_type | title }}: {{ "{:,.0f} km".format(distance) }}</span>
{% endfor %}
{% endif %}
{% if total_co2_kg %}
<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 %}
<span class="trip-stat">{{ transport_type | title }} CO₂ {{ "{:,.1f}".format(co2_kg) }} kg</span>
{% endfor %}
{% endif %}
</div>
{% set delta = human_readable_delta(trip.start) %}
{% if delta %}
<div>How long until trip: {{ delta }}</div>
{% endif %}
{% if trip.schengen_compliance %}
<div class="mt-3">
<strong>Schengen Compliance:</strong>
{% if trip.schengen_compliance.is_compliant %}
<span class="badge bg-success">✅ Compliant</span>
{% else %}
<span class="badge bg-danger">❌ Non-compliant</span>
{% endif %}
<div class="text-muted small">
{{ trip.schengen_compliance.total_days_used }}/90 days used
{% if trip.schengen_compliance.is_compliant %}
({{ trip.schengen_compliance.days_remaining }} remaining)
{% else %}
({{ trip.schengen_compliance.days_over_limit }} over limit)
{% endif %}
</div>
</div>
{% endif %}
</div>
{# ---- 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 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 class="trip-section-h">Flight bookings</h3>
{% for item in trip.flight_bookings %}
<div>
{{ item.flights | map(attribute="airline_name") | unique | join(" + ") }}
{% if g.user.is_authenticated and item.booking_reference %}
<strong>booking reference:</strong> {{ item.booking_reference }}
{% 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 %}
</div>
{% 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="trip-event-card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.title }}</a>
<small class="text-muted">{{ display_date_no_year(item.date) }}</small>
</h5>
<p class="card-text">
Address: {{ item.address }}
| Location: {{ 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>
{% endfor %}
{% endif %}
<div class="mt-3">
<h4 class="trip-section-h">Holidays</h4>
{% if holidays %}
<table class="table table-hover w-auto">
{% for item in holidays %}
{% set country = get_country(item.country) %}
<tr>
{% if loop.first or item.date != loop.previtem.date %}
<td class="text-end">{{ display_date(item.date) }}</td>
{% else %}
<td></td>
{% endif %}
<td>{{ country.flag if trip.show_flags }} {{ country.name }}</td>
<td>{{ item.display_name }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>No public holidays during trip.</p>
{% endif %}
</div>
<div class="mt-3">
<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 %}
<tr>
<td class="text-end">{{ display_date(item.as_date) }}</td>
<td>to {{ display_date(item.end_as_date) }}</td>
<td>{{ item.title }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>No UK school holidays during trip.</p>
{% endif %}
</div>
{{ next_and_previous() }}
</div>
</div>
<div class="col-md-6 col-sm-12">
<button id="toggleMapSize" class="btn btn-primary mb-2">Toggle map size</button>
<div id="map" class="half-map">
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for("static", filename="leaflet/leaflet.js") }}"></script>
<script src="{{ url_for("static", filename="leaflet-geodesic/leaflet.geodesic.umd.min.js") }}"></script>
<script src="{{ url_for("static", filename="js/map.js") }}"></script>
<script>
var coordinates = {{ coordinates | tojson }};
var routes = {{ routes | tojson }};
build_map("map", coordinates, routes);
</script>
{% endblock %}