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}")