diff --git a/templates/trip_page.html b/templates/trip_page.html
index c4c153c..20b1c00 100644
--- a/templates/trip_page.html
+++ b/templates/trip_page.html
@@ -99,6 +99,31 @@
{#
Countries: {{ trip.countries_str }}
#}
Locations: {{ trip.locations_str }}
+ {% if destination_times %}
+
+
Destination time zones
+
+
+
+ | Destination |
+ Timezone |
+ Difference from UK |
+ Current local time |
+
+
+
+ {% for item in destination_times %}
+
+ | {{ item.location }} ({{ item.country_name }}) {{ item.country_flag if trip.show_flags }} |
+ {{ item.timezone or "Unknown" }} |
+ {{ item.offset_display }} |
+ {{ item.current_time or "Unknown" }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
{% if total_distance %}
Total distance:
@@ -465,5 +490,46 @@ var routes = {{ routes | tojson }};
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);
+
{% endblock %}
diff --git a/web_view.py b/web_view.py
index 2d4d155..3147883 100755
--- a/web_view.py
+++ b/web_view.py
@@ -11,10 +11,13 @@ import os.path
import sys
import time
import traceback
+import typing
from collections import defaultdict
from datetime import date, datetime, timedelta, timezone
+from zoneinfo import ZoneInfo
import flask
+import pytz
import werkzeug
import werkzeug.debug.tbtools
import yaml
@@ -761,6 +764,104 @@ def get_prev_current_and_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/")
def trip_page(start: str) -> str:
"""Individual trip page."""
@@ -802,6 +903,7 @@ def trip_page(start: str) -> str:
format_list_with_ampersand=format_list_with_ampersand,
holidays=agenda.holidays.get_trip_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,
trip_weather=trip_weather,
)