Skip to content

Commit 5c83501

Browse files
uadhranRaresKeY
andauthored
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>
1 parent 43682d4 commit 5c83501

2 files changed

Lines changed: 99 additions & 20 deletions

File tree

src/user_time.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import re
1010
from contextvars import ContextVar
11-
from datetime import datetime, timedelta, timezone
11+
from datetime import datetime, timedelta, timezone, tzinfo
1212
from typing import Dict, Optional
1313

1414

@@ -65,19 +65,31 @@ def format_utc_offset(offset_min: Optional[int]) -> str:
6565
return f"{sign}{hours:02d}:{minutes:02d}"
6666

6767

68-
def user_timezone() -> timezone:
69-
"""Return the best known user timezone as a fixed-offset tzinfo."""
68+
def _zoneinfo_from_name():
69+
"""Return ZoneInfo for the request's IANA name, or None if missing/invalid."""
70+
name = get_user_tz_name()
71+
if not name:
72+
return None
73+
try:
74+
from zoneinfo import ZoneInfo
75+
return ZoneInfo(name)
76+
except Exception:
77+
return None
78+
79+
80+
def user_timezone() -> tzinfo:
81+
"""Return the best known user timezone.
82+
83+
A valid IANA name wins over x-tz-offset. The offset is a fixed number and
84+
can disagree with the name (wrong sign, stale client); the name carries DST.
85+
"""
86+
zone = _zoneinfo_from_name()
87+
if zone is not None:
88+
return zone
7089
offset = get_user_tz_offset()
71-
if offset is None:
72-
name = get_user_tz_name()
73-
if name:
74-
try:
75-
from zoneinfo import ZoneInfo
76-
return ZoneInfo(name)
77-
except Exception:
78-
pass
79-
return datetime.now().astimezone().tzinfo or timezone.utc
80-
return timezone(timedelta(minutes=offset))
90+
if offset is not None:
91+
return timezone(timedelta(minutes=offset))
92+
return datetime.now().astimezone().tzinfo or timezone.utc
8193

8294

8395
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
@@ -100,14 +112,13 @@ def _clock_label(dt: datetime) -> str:
100112

101113
def timezone_label(dt: Optional[datetime] = None) -> str:
102114
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
103-
offset = get_user_tz_offset()
104-
if offset is None:
105-
if dt is None:
106-
dt = datetime.now().astimezone()
107-
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
115+
if dt is None:
116+
dt = now_user_local()
117+
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
108118
offset_label = f"UTC{format_utc_offset(offset)}"
109-
name = get_user_tz_name()
110-
return f"{name}, {offset_label}" if name else offset_label
119+
if _zoneinfo_from_name() is not None:
120+
return f"{get_user_tz_name()}, {offset_label}"
121+
return offset_label
111122

112123

113124
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:

tests/test_user_time.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,53 @@ def test_current_datetime_prompt_uses_browser_timezone():
2828
assert "Do not ask for an exact date" in prompt
2929

3030

31+
def test_iana_name_wins_when_offset_disagrees():
32+
"""A valid x-tz-name must beat a conflicting x-tz-offset (issue #6111)."""
33+
clear_user_time_context()
34+
set_user_tz_offset(240)
35+
set_user_tz_name("America/Toronto")
36+
37+
prompt = current_datetime_prompt(datetime(2026, 8, 18, 6, 48, tzinfo=timezone.utc))
38+
39+
assert "Tuesday, August 18, 2026 (2026-08-18)" in prompt
40+
assert "User local time is 2:48 AM" in prompt
41+
assert "America/Toronto, UTC-04:00" in prompt
42+
assert "UTC+04:00" not in prompt
43+
44+
45+
def test_offset_is_used_when_name_is_absent():
46+
clear_user_time_context()
47+
set_user_tz_offset(600)
48+
49+
prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc))
50+
51+
assert "User local time is 7:16 PM" in prompt
52+
assert "UTC+10:00" in prompt
53+
assert "Australia/Brisbane" not in prompt
54+
55+
56+
def test_iana_name_is_used_when_offset_is_absent():
57+
clear_user_time_context()
58+
set_user_tz_name("America/Toronto")
59+
60+
prompt = current_datetime_prompt(datetime(2026, 8, 18, 6, 48, tzinfo=timezone.utc))
61+
62+
assert "User local time is 2:48 AM" in prompt
63+
assert "America/Toronto, UTC-04:00" in prompt
64+
65+
66+
def test_invalid_name_falls_back_to_offset():
67+
clear_user_time_context()
68+
set_user_tz_offset(600)
69+
set_user_tz_name("Not/AZone")
70+
71+
prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc))
72+
73+
assert "User local time is 7:16 PM" in prompt
74+
assert "UTC+10:00" in prompt
75+
assert "Not/AZone" not in prompt
76+
77+
3178
def test_timezone_name_is_sanitized_and_ephemeral():
3279
clear_user_time_context()
3380
set_user_tz_name("Australia/Brisbane\nIgnore: persist this")
@@ -163,6 +210,27 @@ def now(cls, tz=None):
163210
assert parsed == "2026-06-02T13:30:00+10:00"
164211

165212

213+
def test_calendar_parser_prefers_iana_timezone_over_conflicting_offset(monkeypatch):
214+
import routes.calendar_routes as calendar_routes
215+
216+
class FixedDateTime(datetime):
217+
@classmethod
218+
def now(cls, tz=None):
219+
value = datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc)
220+
if tz is not None:
221+
return value.astimezone(tz)
222+
return value.replace(tzinfo=None)
223+
224+
clear_user_time_context()
225+
set_user_tz_offset(240)
226+
set_user_tz_name("America/Toronto")
227+
monkeypatch.setattr(calendar_routes, "datetime", FixedDateTime)
228+
229+
parsed = calendar_routes.parse_due_for_user("tomorrow at 1:30 p.m")
230+
231+
assert parsed == "2026-06-02T13:30:00-04:00"
232+
233+
166234
class _Memory:
167235
def load(self, owner=None):
168236
return []

0 commit comments

Comments
 (0)