Repository: ManiMozaffar/aioclock

Issue:
At Trigger Returns Negative Sleep Time, Causing Infinite Job Execution
At trigger would return "negative" sleep time, resulting to constantly keep running job forever.
To replicate it

```python
def test_cross_timezone():
    # `NOW` is same day as the trigger, but trigger should be next week with different timezone
    trigger = At(hour=3, tz="Europe/Berlin", at="every saturday")
    assert (
        trigger._get_next_ts(
            datetime.fromtimestamp(1720296232, tz=zoneinfo.ZoneInfo("Europe/Istanbul"))
        )
        == 532568
    )
```

The value should be 532568, but it's negative.

Discussion:

Patch:
diff --git a/aioclock/triggers.py b/aioclock/triggers.py
index 51df76a..e566bc2 100644
--- a/aioclock/triggers.py
+++ b/aioclock/triggers.py
@@ -20,13 +20,37 @@ from typing import Annotated, Generic, Literal, TypeVar, Union
 import zoneinfo
 from annotated_types import Interval
 from croniter import croniter
+from dateutil.relativedelta import relativedelta
 from pydantic import BaseModel, Field, PositiveInt, model_validator
 
-from aioclock.custom_types import EveryT, PositiveNumber, Triggers
+from aioclock.custom_types import PositiveNumber, Triggers
 
 TriggerTypeT = TypeVar("TriggerTypeT")
 
 
+WEEKDAY_MAPPER: dict[
+    Literal[
+        "every monday",
+        "every tuesday",
+        "every wednesday",
+        "every thursday",
+        "every friday",
+        "every saturday",
+        "every sunday",
+        "every day",
+    ],
+    int,
+] = {
+    "every monday": 0,
+    "every tuesday": 1,
+    "every wednesday": 2,
+    "every thursday": 3,
+    "every friday": 4,
+    "every saturday": 5,
+    "every sunday": 6,
+}
+
+
 class BaseTrigger(BaseModel, ABC, Generic[TriggerTypeT]):
     """
     Base class for all triggers.
@@ -414,44 +438,24 @@ class At(LoopController[Literal[Triggers.AT]]):
 
         return self
 
-    def _shift_to_week(self, target_time: datetime, tz_aware_now: datetime):
-        target_weekday: dict[EveryT, Union[int, None]] = {
-            "every monday": 0,
-            "every tuesday": 1,
-            "every wednesday": 2,
-            "every thursday": 3,
-            "every friday": 4,
-            "every saturday": 5,
-            "every sunday": 6,
-            "every day": None,
-        }[self.at]
-
-        if target_weekday is None:
-            if target_time < tz_aware_now:
-                target_time += timedelta(days=1)
-            return target_time
-
-        days_ahead = target_weekday - tz_aware_now.weekday()  # type: ignore
-        if days_ahead <= 0:
-            days_ahead += 7
-
+    def _shift_to_declared_weekday(self, target_time: datetime, tz_aware_now: datetime):
         if self.at == "every day":
-            target_time += timedelta(days=(1 if target_time < tz_aware_now else 0))
+            if tz_aware_now > target_time:  # if the time is already passed, then shift to next day
+                target_time += timedelta(days=1)
             return target_time
 
-        # 1 second error
-        error_margin = WEEK_TO_SECOND - 1
-        if days_ahead == 7 and target_time.timestamp() - tz_aware_now.timestamp() < error_margin:
-            # date is today, and event is about to be triggered today. so no need to shift to 7 days.
-            return target_time
+        target_weekday: int = WEEKDAY_MAPPER[self.at]
+        if tz_aware_now > target_time:  # if the time is already passed, then shift to next week
+            return target_time + relativedelta(weeks=1)
 
+        days_ahead = abs(target_weekday - tz_aware_now.weekday())
         return target_time + timedelta(days_ahead)
 
     def _get_next_ts(self, now: datetime) -> float:
         target_time = deepcopy(now).replace(
             hour=self.hour, minute=self.minute, second=self.second, microsecond=0
         )
-        target_time = self._shift_to_week(target_time, now)
+        target_time = self._shift_to_declared_weekday(target_time, now)
         return (target_time - now).total_seconds()
 
     async def get_waiting_time_till_next_trigger(self, now: Union[datetime, None] = None):


Test patch:
diff --git a/tests/test_triggers.py b/tests/test_triggers.py
index db70daa..7dddf48 100644
--- a/tests/test_triggers.py
+++ b/tests/test_triggers.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timedelta
 
 import pytest
 import zoneinfo
@@ -41,33 +41,43 @@ def test_at_trigger():
 
     # test every day
     trigger = At(at="every day", hour=14, second=59, tz="Europe/Istanbul")
-    val = trigger._get_next_ts(
-        datetime(
-            year=2024,
-            month=3,
-            day=31,
-            hour=14,
-            minute=0,
-            second=0,
-            tzinfo=zoneinfo.ZoneInfo("Europe/Istanbul"),
-        )
+    this_sunday = datetime(
+        year=2024,
+        month=3,
+        day=31,
+        hour=14,
+        minute=0,
+        second=0,
+        tzinfo=zoneinfo.ZoneInfo("Europe/Istanbul"),
     )
+    val = trigger._get_next_ts(this_sunday)
     assert val == 59
 
     # test next week
     trigger = At(at="every saturday", hour=14, second=0, tz="Europe/Istanbul")
-    val = trigger._get_next_ts(
-        datetime(
-            year=2024,
-            month=3,
-            day=31,
-            hour=14,
-            minute=0,
-            second=0,
-            tzinfo=zoneinfo.ZoneInfo("Europe/Istanbul"),
+    val = trigger._get_next_ts(this_sunday)
+    assert val == 86400
+
+    # right NOW
+    trigger = At(at="every sunday", hour=14, tz="Europe/Istanbul")
+    val = trigger._get_next_ts(this_sunday)
+    assert val == 0
+
+    # next week but 1h before than now
+    trigger = At(at="every sunday", hour=13, tz="Europe/Istanbul")
+    val = trigger._get_next_ts(this_sunday)
+    assert val == timedelta(days=7).total_seconds() - timedelta(hours=1).total_seconds()
+
+
+def test_cross_timezone():
+    # `NOW` is same day as the trigger, but trigger should be next week with different timezone
+    trigger = At(hour=3, tz="Europe/Berlin", at="every saturday")
+    assert (
+        trigger._get_next_ts(
+            datetime.fromtimestamp(1720296232, tz=zoneinfo.ZoneInfo("Europe/Istanbul"))
         )
+        == 532568
     )
-    assert val == 518400
 
 
 @pytest.mark.asyncio