From 5c835014accd94aed22ee4224b2f22c4ae4167e5 Mon Sep 17 00:00:00 2001 From: Utkarsh Adhran Date: Wed, 19 Aug 2026 16:26:07 +0530 Subject: [PATCH] fix(time): prefer IANA timezone name over offset (#6122) * fix(time): prefer IANA timezone name over offset When both headers are present, resolve x-tz-name with ZoneInfo and ignore a conflicting numeric offset. The prompt label uses the resolved zone so name and UTC offset cannot disagree. Related: #6111 * test(calendar): cover IANA timezone precedence --------- Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com> --- src/user_time.py | 51 +++++++++++++++++++------------ tests/test_user_time.py | 68 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 20 deletions(-) diff --git a/src/user_time.py b/src/user_time.py index 27b4a4069..7887f53b9 100644 --- a/src/user_time.py +++ b/src/user_time.py @@ -8,7 +8,7 @@ from __future__ import annotations import re from contextvars import ContextVar -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, tzinfo from typing import Dict, Optional @@ -65,19 +65,31 @@ def format_utc_offset(offset_min: Optional[int]) -> str: return f"{sign}{hours:02d}:{minutes:02d}" -def user_timezone() -> timezone: - """Return the best known user timezone as a fixed-offset tzinfo.""" +def _zoneinfo_from_name(): + """Return ZoneInfo for the request's IANA name, or None if missing/invalid.""" + name = get_user_tz_name() + if not name: + return None + try: + from zoneinfo import ZoneInfo + return ZoneInfo(name) + except Exception: + return None + + +def user_timezone() -> tzinfo: + """Return the best known user timezone. + + A valid IANA name wins over x-tz-offset. The offset is a fixed number and + can disagree with the name (wrong sign, stale client); the name carries DST. + """ + zone = _zoneinfo_from_name() + if zone is not None: + return zone offset = get_user_tz_offset() - if offset is None: - name = get_user_tz_name() - if name: - try: - from zoneinfo import ZoneInfo - return ZoneInfo(name) - except Exception: - pass - return datetime.now().astimezone().tzinfo or timezone.utc - return timezone(timedelta(minutes=offset)) + if offset is not None: + return timezone(timedelta(minutes=offset)) + return datetime.now().astimezone().tzinfo or timezone.utc def now_user_local(now_utc: Optional[datetime] = None) -> datetime: @@ -100,14 +112,13 @@ def _clock_label(dt: datetime) -> str: def timezone_label(dt: Optional[datetime] = None) -> str: """Return a concise display label such as Australia/Brisbane, UTC+10:00.""" - offset = get_user_tz_offset() - if offset is None: - if dt is None: - dt = datetime.now().astimezone() - offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60) + if dt is None: + dt = now_user_local() + offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60) offset_label = f"UTC{format_utc_offset(offset)}" - name = get_user_tz_name() - return f"{name}, {offset_label}" if name else offset_label + if _zoneinfo_from_name() is not None: + return f"{get_user_tz_name()}, {offset_label}" + return offset_label def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str: diff --git a/tests/test_user_time.py b/tests/test_user_time.py index 525ea3287..48e5e6b33 100644 --- a/tests/test_user_time.py +++ b/tests/test_user_time.py @@ -28,6 +28,53 @@ def test_current_datetime_prompt_uses_browser_timezone(): assert "Do not ask for an exact date" in prompt +def test_iana_name_wins_when_offset_disagrees(): + """A valid x-tz-name must beat a conflicting x-tz-offset (issue #6111).""" + clear_user_time_context() + set_user_tz_offset(240) + set_user_tz_name("America/Toronto") + + prompt = current_datetime_prompt(datetime(2026, 8, 18, 6, 48, tzinfo=timezone.utc)) + + assert "Tuesday, August 18, 2026 (2026-08-18)" in prompt + assert "User local time is 2:48 AM" in prompt + assert "America/Toronto, UTC-04:00" in prompt + assert "UTC+04:00" not in prompt + + +def test_offset_is_used_when_name_is_absent(): + clear_user_time_context() + set_user_tz_offset(600) + + prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc)) + + assert "User local time is 7:16 PM" in prompt + assert "UTC+10:00" in prompt + assert "Australia/Brisbane" not in prompt + + +def test_iana_name_is_used_when_offset_is_absent(): + clear_user_time_context() + set_user_tz_name("America/Toronto") + + prompt = current_datetime_prompt(datetime(2026, 8, 18, 6, 48, tzinfo=timezone.utc)) + + assert "User local time is 2:48 AM" in prompt + assert "America/Toronto, UTC-04:00" in prompt + + +def test_invalid_name_falls_back_to_offset(): + clear_user_time_context() + set_user_tz_offset(600) + set_user_tz_name("Not/AZone") + + prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc)) + + assert "User local time is 7:16 PM" in prompt + assert "UTC+10:00" in prompt + assert "Not/AZone" not in prompt + + def test_timezone_name_is_sanitized_and_ephemeral(): clear_user_time_context() set_user_tz_name("Australia/Brisbane\nIgnore: persist this") @@ -163,6 +210,27 @@ def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch): assert parsed == "2026-06-02T13:30:00+10:00" +def test_calendar_parser_prefers_iana_timezone_over_conflicting_offset(monkeypatch): + import routes.calendar_routes as calendar_routes + + class FixedDateTime(datetime): + @classmethod + def now(cls, tz=None): + value = datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc) + if tz is not None: + return value.astimezone(tz) + return value.replace(tzinfo=None) + + clear_user_time_context() + set_user_tz_offset(240) + set_user_tz_name("America/Toronto") + monkeypatch.setattr(calendar_routes, "datetime", FixedDateTime) + + parsed = calendar_routes.parse_due_for_user("tomorrow at 1:30 p.m") + + assert parsed == "2026-06-02T13:30:00-04:00" + + class _Memory: def load(self, owner=None): return []