diff --git a/buzz/api/__init__.py b/buzz/api/__init__.py index 77aa5e0e..045cb898 100644 --- a/buzz/api/__init__.py +++ b/buzz/api/__init__.py @@ -233,6 +233,7 @@ def get_event_booking_data(event_route: str) -> dict: "start_time": event_doc.start_time, "end_time": event_doc.end_time, "time_zone": event_doc.time_zone, + "time_zone_label": event_doc.time_zone_label, "venue": event_doc.venue, "medium": event_doc.medium, "category": event_doc.category, diff --git a/buzz/api/forms.py b/buzz/api/forms.py index 7ec77327..f9544c20 100644 --- a/buzz/api/forms.py +++ b/buzz/api/forms.py @@ -229,6 +229,7 @@ def get_custom_form_data(event_route: str, form_route: str) -> dict: "start_time": event_doc.start_time, "end_time": event_doc.end_time, "time_zone": event_doc.time_zone, + "time_zone_label": event_doc.time_zone_label, "venue": event_doc.venue, "medium": event_doc.medium, "short_description": event_doc.short_description, diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index 3e8564a0..992f98cc 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -18,6 +18,7 @@ "start_date", "start_time", "time_zone", + "time_zone_label", "column_break_cjby", "end_date", "end_time", @@ -175,6 +176,12 @@ "fieldtype": "Autocomplete", "label": "Time Zone" }, + { + "fieldname": "time_zone_label", + "fieldtype": "Data", + "label": "Time Zone Label", + "read_only": 1 + }, { "fieldname": "banner_image", "fieldtype": "Attach Image", @@ -595,7 +602,7 @@ "link_fieldname": "event" } ], - "modified": "2026-03-23 17:37:37.770911", + "modified": "2026-07-23 12:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Event", diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index 09bedf6d..dc001142 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -5,10 +5,10 @@ from frappe import _ from frappe.model.document import Document from frappe.model.naming import append_number_if_name_exists -from frappe.utils.data import get_time, time_diff_in_seconds +from frappe.utils.data import get_datetime, get_time, time_diff_in_seconds from buzz.api.forms import validate_excluded_fields -from buzz.utils import only_if_app_installed +from buzz.utils import get_time_zone_label, only_if_app_installed # Top-level dashboard route segments (/b/) an event route must not shadow. RESERVED_EVENT_ROUTES = { @@ -84,6 +84,7 @@ class BuzzEvent(Document): ticket_email_template: DF.Link | None ticket_print_format: DF.Link | None time_zone: DF.Autocomplete | None + time_zone_label: DF.Data | None title: DF.Data venue: DF.Link | None # end: auto-generated types @@ -95,6 +96,17 @@ def validate(self): self.validate_tax_settings() self.validate_guest_verification_config() self.validate_custom_forms() + self.set_time_zone_label() + + def set_time_zone_label(self): + # validate runs before the mandatory check, so dates may still be empty here + if not (self.time_zone and self.start_date and self.start_time): + self.time_zone_label = "" + return + + # computed at event start so DST zones get the abbreviation in effect then + event_start = get_datetime(f"{self.start_date} {self.start_time}") + self.time_zone_label = get_time_zone_label(self.time_zone, event_start) def validate_custom_forms(self): for form in self.custom_forms: diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py index f2189bde..fb7e5882 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -10,6 +10,8 @@ from buzz.api import are_registrations_closed from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, create_from_template from buzz.events.doctype.event_template.event_template import create_template_from_event +from buzz.patches.set_time_zone_label_for_existing_events import execute as backfill_time_zone_labels +from buzz.utils import get_time_zone_label class TestBuzzEvent(FrappeTestCase): @@ -890,3 +892,133 @@ def test_event_end_fallback_is_also_timezone_consistent(self): after_end = datetime(2026, 6, 15, 16, 31, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) with patch("buzz.api.get_datetime_in_timezone", return_value=after_end): self.assertTrue(are_registrations_closed(event)) + + +class TestTimeZoneLabel(FrappeTestCase): + """Tests for get_time_zone_label: IANA name -> short display label.""" + + def test_tzdb_abbreviation_when_alphabetic(self): + """Zones where tzdata ships a real abbreviation use it directly.""" + reference = datetime(2026, 6, 15, 12, 0) + self.assertEqual(get_time_zone_label("Asia/Kolkata", reference), "IST") + self.assertEqual(get_time_zone_label("Asia/Tokyo", reference), "JST") + self.assertEqual(get_time_zone_label("Africa/Nairobi", reference), "EAT") + self.assertEqual(get_time_zone_label("UTC", reference), "UTC") + + def test_dst_variant_follows_reference_date(self): + """DST zones get the abbreviation in effect on the reference date.""" + winter = datetime(2026, 1, 15, 12, 0) + summer = datetime(2026, 7, 15, 12, 0) + self.assertEqual(get_time_zone_label("America/New_York", winter), "EST") + self.assertEqual(get_time_zone_label("America/New_York", summer), "EDT") + self.assertEqual(get_time_zone_label("Europe/Berlin", winter), "CET") + self.assertEqual(get_time_zone_label("Europe/Berlin", summer), "CEST") + + def test_curated_abbreviation_when_tzdb_is_numeric(self): + """Zones where tzdata returns a bare offset fall back to the curated map.""" + reference = datetime(2026, 6, 15, 12, 0) + self.assertEqual(get_time_zone_label("Asia/Dubai", reference), "GST") + self.assertEqual(get_time_zone_label("Asia/Riyadh", reference), "AST") + self.assertEqual(get_time_zone_label("Asia/Bangkok", reference), "ICT") + self.assertEqual(get_time_zone_label("Asia/Kathmandu", reference), "NPT") + + def test_gmt_offset_fallback_for_unmapped_zone(self): + """Zones outside tzdata abbreviations and the curated map show a GMT offset.""" + reference = datetime(2026, 6, 15, 12, 0) + # Bhutan: tzname is "+06", not in the curated map + self.assertEqual(get_time_zone_label("Asia/Thimphu", reference), "GMT+6") + # Myanmar: half-hour offset formatting + self.assertEqual(get_time_zone_label("Asia/Yangon", reference), "GMT+6:30") + # Marquesas: negative half-hour offset + self.assertEqual(get_time_zone_label("Pacific/Marquesas", reference), "GMT-9:30") + + def test_empty_or_invalid_time_zone_returns_empty(self): + reference = datetime(2026, 6, 15, 12, 0) + self.assertEqual(get_time_zone_label(None, reference), "") + self.assertEqual(get_time_zone_label("", reference), "") + self.assertEqual(get_time_zone_label("Not/A_Zone", reference), "") + + def test_current_iana_names_for_renamed_zones(self): + """Renamed zones resolve under both the legacy and current IANA names.""" + reference = datetime(2026, 6, 15, 12, 0) + self.assertEqual(get_time_zone_label("Asia/Ho_Chi_Minh", reference), "ICT") + self.assertEqual(get_time_zone_label("America/Nuuk", reference), "WGT") + + def test_aware_reference_datetime_converted_not_reinterpreted(self): + """US DST ends 2026-11-01 06:00 UTC; 05:30 UTC is still 01:30 EDT. + + Naive .replace() would read 05:30 as New York wall clock (past the + switch, EST); a correct conversion lands on EDT. + """ + aware_reference = datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc) + self.assertEqual(get_time_zone_label("America/New_York", aware_reference), "EDT") + + +class TestEventTimeZoneLabelField(FrappeTestCase): + """Saving a Buzz Event stores the display label for its time zone.""" + + def tearDown(self): + frappe.db.rollback() + + def _make_event(self, **overrides): + event_defaults = { + "doctype": "Buzz Event", + "title": "TZ Label Test Event", + "category": "Test Category", + "host": "Test Host", + "start_date": "2026-03-05", + "end_date": "2026-03-06", + "start_time": "9:00:00", + "end_time": "18:00:00", + } + event_defaults.update(overrides) + return frappe.get_doc(event_defaults) + + @classmethod + def setUpClass(cls): + super().setUpClass() + TestBuzzEvent.create_test_fixtures() + + def test_label_set_on_insert(self): + event = self._make_event(time_zone="Asia/Kolkata") + event.insert() + self.assertEqual(event.time_zone_label, "IST") + + def test_label_updates_when_time_zone_changes(self): + event = self._make_event(time_zone="Asia/Kolkata") + event.insert() + event.time_zone = "Asia/Dubai" + event.save() + self.assertEqual(event.time_zone_label, "GST") + + def test_label_cleared_when_time_zone_removed(self): + event = self._make_event(time_zone="Asia/Kolkata") + event.insert() + event.time_zone = "" + event.save() + self.assertEqual(event.time_zone_label, "") + + def test_label_uses_event_start_date_for_dst(self): + """July New York event shows EDT, not EST.""" + event = self._make_event( + time_zone="America/New_York", + start_date="2026-07-10", + end_date="2026-07-10", + ) + event.insert() + self.assertEqual(event.time_zone_label, "EDT") + + def test_backfill_patch_skips_events_missing_start_fields(self): + """Legacy rows can have time_zone without start fields; patch must not abort.""" + event = self._make_event(time_zone="Asia/Kolkata") + event.insert() + frappe.db.set_value( + "Buzz Event", + event.name, + {"start_time": None, "time_zone_label": ""}, + update_modified=False, + ) + + backfill_time_zone_labels() + + self.assertEqual(frappe.db.get_value("Buzz Event", event.name, "time_zone_label"), "") diff --git a/buzz/patches.txt b/buzz/patches.txt index 8a811c98..3c8ab29b 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -11,4 +11,5 @@ buzz.patches.migrate_offline_payment_to_methods buzz.patches.populate_slug_in_event_category buzz.patches.set_applies_to_for_existing_coupons buzz.patches.set_payment_status_for_existing_bookings -buzz.patches.normalize_phone_format \ No newline at end of file +buzz.patches.normalize_phone_format +buzz.patches.set_time_zone_label_for_existing_events diff --git a/buzz/patches/set_time_zone_label_for_existing_events.py b/buzz/patches/set_time_zone_label_for_existing_events.py new file mode 100644 index 00000000..73753261 --- /dev/null +++ b/buzz/patches/set_time_zone_label_for_existing_events.py @@ -0,0 +1,20 @@ +import frappe +from frappe.utils.data import get_datetime + +from buzz.utils import get_time_zone_label + + +def execute(): + events = frappe.get_all( + "Buzz Event", + filters={ + "time_zone": ("is", "set"), + "start_date": ("is", "set"), + "start_time": ("is", "set"), + }, + fields=["name", "time_zone", "start_date", "start_time"], + ) + for event in events: + event_start = get_datetime(f"{event.start_date} {event.start_time}") + label = get_time_zone_label(event.time_zone, event_start) + frappe.db.set_value("Buzz Event", event.name, "time_zone_label", label, update_modified=False) diff --git a/buzz/utils.py b/buzz/utils.py index 5cfe2eb2..6b66337b 100644 --- a/buzz/utils.py +++ b/buzz/utils.py @@ -1,8 +1,12 @@ import functools +import re from collections.abc import Callable +from datetime import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from frappe.utils import now_datetime def is_app_installed(app_name: str) -> bool: @@ -189,3 +193,84 @@ def generate_ics_file(event_doc, attendee_email: str): # nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti return frappe.render_template("templates/ics/ics.jinja2", context, is_path=True) + + +# Curated abbreviations for zones where tzdata only provides a numeric offset +# (tzdata dropped invented abbreviations in 2017). Zones with real tzdata +# abbreviations (IST, EST, CET, ...) never reach this map. +# ponytail: DST-observing zones here (e.g. Chile) are pinned to their standard +# form; extend get_time_zone_label with per-date variants if that ever matters. +TIMEZONE_ABBREVIATIONS = { + "America/Araguaina": "BRT", + "America/Argentina/Buenos_Aires": "ART", + "America/Bogota": "COT", + "America/Caracas": "VET", + "America/Godthab": "WGT", + "America/Nuuk": "WGT", + "America/Lima": "PET", + "America/Montevideo": "UYT", + "America/Santiago": "CLT", + "America/Sao_Paulo": "BRT", + "Asia/Aden": "AST", + "Asia/Almaty": "ALMT", + "Asia/Baghdad": "AST", + "Asia/Bahrain": "AST", + "Asia/Baku": "AZT", + "Asia/Bangkok": "ICT", + "Asia/Dacca": "BST", + "Asia/Dhaka": "BST", + "Asia/Dubai": "GST", + "Asia/Ho_Chi_Minh": "ICT", + "Asia/Irkutsk": "IRKT", + "Asia/Kabul": "AFT", + "Asia/Kathmandu": "NPT", + "Asia/Krasnoyarsk": "KRAT", + "Asia/Kuwait": "AST", + "Asia/Muscat": "GST", + "Asia/Novosibirsk": "NOVT", + "Asia/Qatar": "AST", + "Asia/Riyadh": "AST", + "Asia/Saigon": "ICT", + "Asia/Tashkent": "UZT", + "Asia/Tehran": "IRST", + "Asia/Yekaterinburg": "YEKT", + "Atlantic/Azores": "AZOT", + "Atlantic/Cape_Verde": "CVT", + "Europe/Istanbul": "TRT", +} + + +def get_time_zone_label(time_zone: str | None, reference_datetime: datetime | None = None) -> str: + """Short display label for an IANA time zone, e.g. "IST", "GST", "GMT+5:45". + + Resolution order: tzdata abbreviation for the reference date (DST-aware), + then the curated map, then a formatted GMT offset. + """ + if not time_zone: + return "" + + try: + zone = ZoneInfo(time_zone) + except (ZoneInfoNotFoundError, ValueError): + return "" + + reference = reference_datetime or now_datetime() + if reference.tzinfo: + moment = reference.astimezone(zone) + else: + moment = reference.replace(tzinfo=zone) + + abbreviation = moment.tzname() + if re.fullmatch(r"[A-Z]{2,5}", abbreviation): + return abbreviation + + if time_zone in TIMEZONE_ABBREVIATIONS: + return TIMEZONE_ABBREVIATIONS[time_zone] + + total_minutes = int(moment.utcoffset().total_seconds()) // 60 + sign = "+" if total_minutes >= 0 else "-" + hours, minutes = divmod(abs(total_minutes), 60) + label = f"GMT{sign}{hours}" + if minutes: + label += f":{minutes:02d}" + return label diff --git a/dashboard/src/components/EventDetailsHeader.vue b/dashboard/src/components/EventDetailsHeader.vue index 4889e1a3..27e7198a 100644 --- a/dashboard/src/components/EventDetailsHeader.vue +++ b/dashboard/src/components/EventDetailsHeader.vue @@ -45,8 +45,8 @@

{{ formatEventTime(eventDetails.start_time, eventDetails.end_time) }} - ({{ eventDetails.time_zone }})({{ eventDetails.time_zone_label }})

diff --git a/dashboard/src/types/Events/BuzzEvent.ts b/dashboard/src/types/Events/BuzzEvent.ts index 8cd758db..0c464be1 100644 --- a/dashboard/src/types/Events/BuzzEvent.ts +++ b/dashboard/src/types/Events/BuzzEvent.ts @@ -34,6 +34,8 @@ export interface BuzzEvent { start_time?: string /** Time Zone : Autocomplete */ time_zone?: any + /** Time Zone Label : Data */ + time_zone_label?: string /** End Date : Date */ end_date?: string /** End Time : Time */