Add destination time difference and live local times on trip pages

Closes #208
This commit is contained in:
Edward Betts 2026-02-26 14:50:54 +00:00
parent 016039e78f
commit ec413ac310
2 changed files with 168 additions and 0 deletions

View file

@ -99,6 +99,31 @@
<div class="mb-3"> <div class="mb-3">
{# <div>Countries: {{ trip.countries_str }}</div> #} {# <div>Countries: {{ trip.countries_str }}</div> #}
<div>Locations: {{ trip.locations_str }}</div> <div>Locations: {{ trip.locations_str }}</div>
{% 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>
<th>Current local time</th>
</tr>
</thead>
<tbody>
{% for item in destination_times %}
<tr>
<td>{{ item.location }} ({{ item.country_name }}) {{ item.country_flag if trip.show_flags }}</td>
<td>{{ item.timezone or "Unknown" }}</td>
<td class="destination-offset" {% if item.timezone %}data-timezone="{{ item.timezone }}"{% endif %}>{{ item.offset_display }}</td>
<td class="destination-time" {% if item.timezone %}data-timezone="{{ item.timezone }}"{% endif %}>{{ item.current_time or "Unknown" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if total_distance %} {% if total_distance %}
<div>Total distance: <div>Total distance:
@ -465,5 +490,46 @@ var routes = {{ routes | tojson }};
build_map("map", coordinates, routes); build_map("map", coordinates, routes);
function timezoneOffsetLabel(offsetMinutes) {
if (offsetMinutes === 0) {
return "Same time as Bristol";
}
const sign = offsetMinutes > 0 ? "+" : "-";
const abs = Math.abs(offsetMinutes);
const hours = String(Math.floor(abs / 60)).padStart(2, "0");
const minutes = String(abs % 60).padStart(2, "0");
return `${sign}${hours}:${minutes} vs Bristol`;
}
function getOffsetMinutes(timeZone) {
const now = new Date();
const localInZone = new Date(now.toLocaleString("en-US", { timeZone }));
const localInBristol = new Date(now.toLocaleString("en-US", { timeZone: "Europe/London" }));
return Math.round((localInZone - localInBristol) / 60000);
}
function updateDestinationTimes() {
for (const el of document.querySelectorAll(".destination-time[data-timezone]")) {
const tz = el.dataset.timezone;
el.textContent = new Intl.DateTimeFormat("en-GB", {
timeZone: tz,
weekday: "short",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(new Date());
}
for (const el of document.querySelectorAll(".destination-offset[data-timezone]")) {
const tz = el.dataset.timezone;
const offset = getOffsetMinutes(tz);
el.textContent = timezoneOffsetLabel(offset);
}
}
updateDestinationTimes();
setInterval(updateDestinationTimes, 1000);
</script> </script>
{% endblock %} {% endblock %}

View file

@ -11,10 +11,13 @@ import os.path
import sys import sys
import time import time
import traceback import traceback
import typing
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import flask import flask
import pytz
import werkzeug import werkzeug
import werkzeug.debug.tbtools import werkzeug.debug.tbtools
import yaml import yaml
@ -761,6 +764,104 @@ def get_prev_current_and_next_trip(
return (prev_trip, current_trip, next_trip) return (prev_trip, current_trip, next_trip)
def _timezone_name_from_datetime(value: typing.Any) -> str | None:
"""Get IANA timezone name from a datetime value if available."""
if not isinstance(value, datetime) or value.tzinfo is None:
return None
key = getattr(value.tzinfo, "key", None)
if isinstance(key, str):
return key
zone = getattr(value.tzinfo, "zone", None)
if isinstance(zone, str):
return zone
return None
def _format_offset_from_bristol(offset_minutes: int) -> str:
"""Format offset from Bristol in +/-HH:MM."""
if offset_minutes == 0:
return "Same time as Bristol"
sign = "+" if offset_minutes > 0 else "-"
hours, mins = divmod(abs(offset_minutes), 60)
return f"{sign}{hours:02d}:{mins:02d} vs Bristol"
def get_destination_timezones(trip: Trip) -> list[StrDict]:
"""Build destination timezone metadata for the trip page."""
per_location: dict[tuple[str, str], list[str]] = defaultdict(list)
for item in trip.accommodation + trip.conferences + trip.events:
location = item.get("location")
country = item.get("country")
if not isinstance(location, str) or not isinstance(country, str):
continue
key = (location, country.lower())
timezone_name = item.get("timezone")
if isinstance(timezone_name, str):
per_location[key].append(timezone_name)
for field in (
"from",
"to",
"date",
"start",
"end",
"attend_start",
"attend_end",
):
candidate = _timezone_name_from_datetime(item.get(field))
if candidate:
per_location[key].append(candidate)
home_now = datetime.now(ZoneInfo("Europe/London"))
destination_times: list[StrDict] = []
for location, country in trip.locations():
country_code = country.alpha_2.lower()
key = (location, country_code)
timezone_name = None
for candidate in per_location.get(key, []):
try:
ZoneInfo(candidate)
timezone_name = candidate
break
except Exception:
continue
if not timezone_name:
country_timezones = pytz.country_timezones.get(country_code, [])
if len(country_timezones) == 1:
timezone_name = country_timezones[0]
offset_display = "Timezone unknown"
current_time = None
if timezone_name:
dest_now = datetime.now(ZoneInfo(timezone_name))
dest_offset = dest_now.utcoffset()
home_offset = home_now.utcoffset()
if dest_offset is not None and home_offset is not None:
offset_minutes = int((dest_offset - home_offset).total_seconds() // 60)
offset_display = _format_offset_from_bristol(offset_minutes)
current_time = dest_now.strftime("%a %H:%M:%S")
destination_times.append(
{
"location": location,
"country_name": country.name,
"country_flag": country.flag,
"timezone": timezone_name,
"offset_display": offset_display,
"current_time": current_time,
}
)
return destination_times
@app.route("/trip/<start>") @app.route("/trip/<start>")
def trip_page(start: str) -> str: def trip_page(start: str) -> str:
"""Individual trip page.""" """Individual trip page."""
@ -802,6 +903,7 @@ def trip_page(start: str) -> str:
format_list_with_ampersand=format_list_with_ampersand, format_list_with_ampersand=format_list_with_ampersand,
holidays=agenda.holidays.get_trip_holidays(trip), holidays=agenda.holidays.get_trip_holidays(trip),
school_holidays=agenda.holidays.get_trip_school_holidays(trip), school_holidays=agenda.holidays.get_trip_school_holidays(trip),
destination_times=get_destination_timezones(trip),
human_readable_delta=agenda.utils.human_readable_delta, human_readable_delta=agenda.utils.human_readable_delta,
trip_weather=trip_weather, trip_weather=trip_weather,
) )