fix: timezone switching spam bug (#664)
* fix: timezone switching spam bug * fix: rate limiting timezone update --------- Co-authored-by: Rafael Uzarowski <uzarowski.rafael@proton.me>
ehl0wr0ld committed
Aug 15, 2025 at 11:35 UTC
edc5bdec7957e53f4aeeffef086298a1813728d5
1 file changed
+92
-30
python/helpers/localization.py
+92
-30
@@ -1,13 +1,16 @@
1
-from datetime import datetime
1
+from datetime import datetime, timezone as dt_timezone, timedelta
2
import pytz # type: ignore
3
4
from python.helpers.print_style import PrintStyle
5
from python.helpers.dotenv import get_dotenv_value, save_dotenv_value
6
7
8
+
9
class Localization:
10
"""
11
Localization class for handling timezone conversions between UTC and local time.
12
+ Now stores a fixed UTC offset (in minutes) derived from the provided timezone name
13
+ to avoid noisy updates when equivalent timezones share the same offset.
14
"""
15
16
# singleton
@@ -20,34 +23,90 @@ class Localization:
23
return cls._instance
24
25
def __init__(self, timezone: str | None = None):
26
+ self.timezone: str = "UTC"
27
+ self._offset_minutes: int = 0
28
+ self._last_timezone_change: datetime | None = None
29
+ # Load persisted values if available
30
+ persisted_tz = str(get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC"))
31
+ persisted_offset = get_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", None)
32
if timezone is not None:
24
- self.set_timezone(timezone) # Use the setter to validate
25
- else:
26
- timezone = str(get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC"))
33
+ # Explicit override
34
self.set_timezone(timezone)
35
+ else:
36
+ # Initialize from persisted values
37
+ self.timezone = persisted_tz
38
+ if persisted_offset is not None:
39
+ try:
40
+ self._offset_minutes = int(str(persisted_offset))
41
+ except Exception:
42
+ self._offset_minutes = self._compute_offset_minutes(self.timezone)
43
+ save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes))
44
+ else:
45
+ # Compute from timezone and persist
46
+ self._offset_minutes = self._compute_offset_minutes(self.timezone)
47
+ save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes))
48
49
def get_timezone(self) -> str:
50
return self.timezone
51
52
+ def _compute_offset_minutes(self, timezone_name: str) -> int:
53
+ tzinfo = pytz.timezone(timezone_name)
54
+ now_in_tz = datetime.now(tzinfo)
55
+ offset = now_in_tz.utcoffset()
56
+ return int(offset.total_seconds() // 60) if offset else 0
57
+
58
+ def get_offset_minutes(self) -> int:
59
+ return self._offset_minutes
60
+
61
+ def _can_change_timezone(self) -> bool:
62
+ """Check if timezone can be changed (rate limited to once per hour)."""
63
+ if self._last_timezone_change is None:
64
+ return True
65
+
66
+ time_diff = datetime.now() - self._last_timezone_change
67
+ return time_diff >= timedelta(hours=1)
68
+
69
def set_timezone(self, timezone: str) -> None:
33
- """Set the timezone, with validation."""
34
- # Validate timezone
70
+ """Set the timezone name, but internally store and compare by UTC offset minutes."""
71
try:
36
- pytz.timezone(timezone)
37
- if timezone != getattr(self, 'timezone', None):
38
- PrintStyle.debug(f"Changing timezone from {getattr(self, 'timezone', 'None')} to {timezone}")
72
+ # Validate timezone and compute its current offset
73
+ _ = pytz.timezone(timezone)
74
+ new_offset = self._compute_offset_minutes(timezone)
75
+
76
+ # If offset changes, check rate limit and update
77
+ if new_offset != getattr(self, "_offset_minutes", None):
78
+ if not self._can_change_timezone():
79
+ return
80
+
81
+ prev_tz = getattr(self, "timezone", "None")
82
+ prev_off = getattr(self, "_offset_minutes", None)
83
+ PrintStyle.debug(
84
+ f"Changing timezone from {prev_tz} (offset {prev_off}) to {timezone} (offset {new_offset})"
85
+ )
86
+ self._offset_minutes = new_offset
87
self.timezone = timezone
88
+ # Persist both the human-readable tz and the numeric offset
89
save_dotenv_value("DEFAULT_USER_TIMEZONE", timezone)
90
+ save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes))
91
+
92
+ # Update rate limit timestamp only when actual change occurs
93
+ self._last_timezone_change = datetime.now()
94
+ else:
95
+ # Offset unchanged: update stored timezone without logging or persisting to avoid churn
96
+ self.timezone = timezone
97
except pytz.exceptions.UnknownTimeZoneError:
98
PrintStyle.error(f"Unknown timezone: {timezone}, defaulting to UTC")
99
self.timezone = "UTC"
44
- # save the default timezone to the environment variable to avoid future errors on startup
100
+ self._offset_minutes = 0
101
+ # save defaults to avoid future errors on startup
102
save_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC")
103
+ save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", "0")
104
105
def localtime_str_to_utc_dt(self, localtime_str: str | None) -> datetime | None:
106
"""
107
Convert a local time ISO string to a UTC datetime object.
108
Returns None if input is None or invalid.
109
+ When input lacks tzinfo, assume the configured fixed UTC offset.
110
"""
111
if not localtime_str:
112
return None
@@ -58,22 +117,27 @@ class Localization:
117
# Try parsing with timezone info first
118
local_datetime_obj = datetime.fromisoformat(localtime_str)
119
if local_datetime_obj.tzinfo is None:
61
- # If no timezone info, assume it's in the configured timezone
62
- local_datetime_obj = pytz.timezone(self.timezone).localize(local_datetime_obj)
120
+ # If no timezone info, assume fixed offset
121
+ local_datetime_obj = local_datetime_obj.replace(
122
+ tzinfo=dt_timezone(timedelta(minutes=self._offset_minutes))
123
+ )
124
except ValueError:
125
# If timezone parsing fails, try without timezone
65
- local_datetime_obj = datetime.fromisoformat(localtime_str.split('Z')[0].split('+')[0])
66
- local_datetime_obj = pytz.timezone(self.timezone).localize(local_datetime_obj)
126
+ base = localtime_str.split('Z')[0].split('+')[0]
127
+ local_datetime_obj = datetime.fromisoformat(base)
128
+ local_datetime_obj = local_datetime_obj.replace(
129
+ tzinfo=dt_timezone(timedelta(minutes=self._offset_minutes))
130
+ )
131
132
# Convert to UTC
69
- return local_datetime_obj.astimezone(pytz.utc)
133
+ return local_datetime_obj.astimezone(dt_timezone.utc)
134
except Exception as e:
135
PrintStyle.error(f"Error converting localtime string to UTC: {e}")
136
return None
137
138
def utc_dt_to_localtime_str(self, utc_dt: datetime | None, sep: str = "T", timespec: str = "auto") -> str | None:
139
"""
76
- Convert a UTC datetime object to a local time ISO string.
140
+ Convert a UTC datetime object to a local time ISO string using the fixed UTC offset.
141
Returns None if input is None.
142
"""
143
if utc_dt is None:
@@ -83,15 +147,15 @@ class Localization:
147
assert utc_dt is not None
148
149
try:
86
- # Ensure datetime is timezone aware
150
+ # Ensure datetime is timezone aware in UTC
151
if utc_dt.tzinfo is None:
88
- utc_dt = pytz.utc.localize(utc_dt)
89
- elif utc_dt.tzinfo != pytz.utc:
90
- utc_dt = utc_dt.astimezone(pytz.utc)
152
+ utc_dt = utc_dt.replace(tzinfo=dt_timezone.utc)
153
+ else:
154
+ utc_dt = utc_dt.astimezone(dt_timezone.utc)
155
92
- # Convert to local time
93
- local_datetime_obj = utc_dt.astimezone(pytz.timezone(self.timezone))
94
- # Return the local time string
156
+ # Convert to local time using fixed offset
157
+ local_tz = dt_timezone(timedelta(minutes=self._offset_minutes))
158
+ local_datetime_obj = utc_dt.astimezone(local_tz)
159
return local_datetime_obj.isoformat(sep=sep, timespec=timespec)
160
except Exception as e:
161
PrintStyle.error(f"Error converting UTC datetime to localtime string: {e}")
@@ -99,8 +163,8 @@ class Localization:
163
164
def serialize_datetime(self, dt: datetime | None) -> str | None:
165
"""
102
- Serialize a datetime object to ISO format string in the user's timezone.
103
- This ensures the frontend receives dates in the correct timezone for display.
166
+ Serialize a datetime object to ISO format string using the user's fixed UTC offset.
167
+ This ensures the frontend receives dates with the correct current offset for display.
168
"""
169
if dt is None:
170
return None
@@ -111,12 +175,10 @@ class Localization:
175
try:
176
# Ensure datetime is timezone aware (if not, assume UTC)
177
if dt.tzinfo is None:
114
- dt = pytz.utc.localize(dt)
115
-
116
- # Convert to the user's timezone
117
- local_timezone = pytz.timezone(self.timezone)
118
- local_dt = dt.astimezone(local_timezone)
178
+ dt = dt.replace(tzinfo=dt_timezone.utc)
179
180
+ local_tz = dt_timezone(timedelta(minutes=self._offset_minutes))
181
+ local_dt = dt.astimezone(local_tz)
182
return local_dt.isoformat()
183
except Exception as e:
184
PrintStyle.error(f"Error serializing datetime: {e}")