@@ -42,6 +42,9 @@ class ScheduleEntry:
4242 trigger_at : datetime | None = None # One-shot
4343 cron : str | None = None # Periodic
4444 last_run : datetime | None = None # For periodic
45+ # Timezone the entry was created in (IANA name)
46+ # Used for evaluating cron expressions in the correct local time
47+ timezone : str | None = None
4548 # Context for routing response back
4649 chat_id : str | None = None
4750 chat_title : str | None = None # Friendly name for the chat
@@ -61,7 +64,8 @@ def next_fire_time(self, timezone: str = "UTC") -> datetime | None:
6164 """Get the next fire time for this entry.
6265
6366 Args:
64- timezone: IANA timezone name for evaluating cron expressions.
67+ timezone: Fallback IANA timezone name for evaluating cron expressions.
68+ If the entry has a stored timezone, that takes precedence.
6569
6670 Returns:
6771 The next fire time in UTC, or None if not schedulable.
@@ -70,18 +74,23 @@ def next_fire_time(self, timezone: str = "UTC") -> datetime | None:
7074 return self .trigger_at
7175
7276 if self .cron :
73- return self ._next_run_time (timezone )
77+ # Use stored timezone if available, otherwise fall back to parameter
78+ tz = self .timezone or timezone
79+ return self ._next_run_time (tz )
7480
7581 return None
7682
7783 def is_due (self , timezone : str = "UTC" ) -> bool :
7884 """Check if this entry is due for execution.
7985
8086 Args:
81- timezone: IANA timezone name for evaluating cron expressions.
87+ timezone: Fallback IANA timezone name for evaluating cron expressions.
88+ If the entry has a stored timezone, that takes precedence.
8289 """
8390 now = datetime .now (UTC )
8491 entry_id = self .id or "?"
92+ # Use stored timezone if available, otherwise fall back to parameter
93+ tz = self .timezone or timezone
8594
8695 if self .trigger_at :
8796 is_due = now >= self .trigger_at
@@ -92,15 +101,15 @@ def is_due(self, timezone: str = "UTC") -> bool:
92101 return is_due
93102
94103 if self .cron :
95- next_run = self ._next_run_time (timezone )
104+ next_run = self ._next_run_time (tz )
96105 if next_run is None :
97106 logger .debug (
98107 f"Entry { entry_id } : cron={ self .cron } , next_run=None, due=False"
99108 )
100109 return False
101110 is_due = now >= next_run
102111 logger .debug (
103- f"Entry { entry_id } : cron='{ self .cron } ' (tz={ timezone } ), "
112+ f"Entry { entry_id } : cron='{ self .cron } ' (tz={ tz } ), "
104113 f"next_run={ next_run .isoformat ()} , now={ now .isoformat ()} , due={ is_due } "
105114 )
106115 return is_due
@@ -110,33 +119,29 @@ def is_due(self, timezone: str = "UTC") -> bool:
110119 def _next_run_time (self , timezone : str = "UTC" ) -> datetime | None :
111120 """Calculate next run time from cron and last_run.
112121
113- Cron expressions are evaluated in the user's timezone so that
114- "0 8 * * *" means 8am local time, not 8am UTC .
122+ Cron expressions are always evaluated in UTC for consistency.
123+ This ensures scheduled times don't shift if system timezone changes .
115124
116125 Args:
117- timezone: IANA timezone name for evaluating cron expressions .
126+ timezone: Unused, kept for API compatibility. Cron always uses UTC .
118127 """
119128 if not self .cron :
120129 return None
121130 try :
122- from zoneinfo import ZoneInfo
123-
124131 from croniter import croniter
125132
126- tz = ZoneInfo (timezone )
127-
133+ # Always use UTC for cron evaluation
128134 if self .last_run :
129- # Normal case: get next run after last_run
130- # Convert last_run to user timezone for cron evaluation
131- base_time = self .last_run .astimezone (tz )
135+ base_time = self .last_run .astimezone (UTC )
132136 else :
133- # New task: wait for the next scheduled occurrence
134- base_time = datetime .now (UTC ).astimezone (tz )
135-
136- # croniter evaluates in the timezone of base_time
137- next_local = croniter (self .cron , base_time ).get_next (datetime )
138- # Convert back to UTC for comparison
139- return next_local .astimezone (UTC )
137+ base_time = datetime .now (UTC )
138+
139+ # croniter evaluates in UTC
140+ next_utc = croniter (self .cron , base_time ).get_next (datetime )
141+ # Ensure it's UTC-aware
142+ if next_utc .tzinfo is None :
143+ next_utc = next_utc .replace (tzinfo = UTC )
144+ return next_utc
140145 except Exception as e :
141146 logger .warning (
142147 f"Failed to parse cron expression '{ self .cron } ' for entry { self .id } : { e } "
@@ -160,6 +165,9 @@ def to_json_line(self) -> str:
160165 if self .last_run :
161166 data ["last_run" ] = self .last_run .isoformat ()
162167
168+ if self .timezone :
169+ data ["timezone" ] = self .timezone
170+
163171 # Context fields
164172 if self .chat_id :
165173 data ["chat_id" ] = self .chat_id
@@ -208,6 +216,7 @@ def parse_datetime(key: str) -> datetime | None:
208216 "trigger_at" ,
209217 "cron" ,
210218 "last_run" ,
219+ "timezone" ,
211220 "chat_id" ,
212221 "chat_title" ,
213222 "user_id" ,
@@ -223,6 +232,7 @@ def parse_datetime(key: str) -> datetime | None:
223232 trigger_at = trigger_at ,
224233 cron = cron ,
225234 last_run = last_run ,
235+ timezone = data .get ("timezone" ),
226236 chat_id = data .get ("chat_id" ),
227237 chat_title = data .get ("chat_title" ),
228238 user_id = data .get ("user_id" ),
0 commit comments