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 + + + + + + + + + + + {% for item in destination_times %} + + + + + + + {% endfor %} + +
DestinationTimezoneDifference from UKCurrent local time
{{ item.location }} ({{ item.country_name }}) {{ item.country_flag if trip.show_flags }}{{ item.timezone or "Unknown" }}{{ item.offset_display }}{{ item.current_time or "Unknown" }}
+
+ {% 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, )