Repository: quartznet/quartznet (2026-03-28T18:37:14Z)

Issue:
TriggerUtils.ComputeFireTimesBetween does not handle DST properly
Hi,
I think I found a problem with the generation of the fire times when crossing the DST date.
I can reproduce it on the 3.2.4 with .NET 4.7.2.

```
 DateTime startDateTime = new DateTime(2020, 10, 25);
            DateTimeOffset startDateTimeOffset = new DateTimeOffset(startDateTime);
            int daysAhead = 3;

            TriggerBuilder triggerBuilder = TriggerBuilder.Create()
                .WithIdentity("id")
                .ForJob("job")
                .StartAt(new DateTimeOffset(startDateTime.AddMinutes(1).AddSeconds(5)))
                .WithDailyTimeIntervalSchedule(
                    scheduleBuilder =>
                    {
                        scheduleBuilder.OnEveryDay().WithIntervalInHours(24);
                        scheduleBuilder
                            .StartingDailyAt(new TimeOfDay(0, 1, 5))
                            .EndingDailyAt(new TimeOfDay(23, 59, 59))
                            .WithMisfireHandlingInstructionDoNothing();
                    });

            IReadOnlyList<DateTimeOffset> fireTimes =
                TriggerUtils.ComputeFireTimesBetween((
                IOperableTrigger)triggerBuilder.Build(),
                null,
                startDateTimeOffset,
                startDateTimeOffset.AddDays(daysAhead));

            Assert.AreEqual(new DateTimeOffset(new DateTime(2020, 10, 25, 0, 1, 5)), fireTimes.First().LocalDateTime);
            Assert.AreEqual(new DateTimeOffset(new DateTime(2020, 10, 26, 0, 1, 5)), fireTimes.Skip(1).First().LocalDateTime);
            Assert.AreEqual(new DateTimeOffset(new DateTime(2020, 10, 27, 0, 1, 5)), fireTimes.Skip(2).First().LocalDateTime);
```

Expected: According to the setup I would expect 3 dates given in the assert.

Actual: 4 dates, with additional DateTime(2020, 10, 25, 23, 1, 5).


kind regards,
Kamil


Discussion:
Hey Everyone. Is there any chance that the issue gets some attention? 
I'm hoping that someone could look at it and let us know if we can count on fixing it or not.
Any answer would be appreciated :)

Best, M.
So... no? ;)
I'm not sure who "we" are that you referred as interested party who wants to count on someone fixing issues. But it would be great if this party would like to contribute with tests and fixes.



Patch:
diff --git a/src/Quartz/Impl/Triggers/DailyTimeIntervalTriggerImpl.cs b/src/Quartz/Impl/Triggers/DailyTimeIntervalTriggerImpl.cs
index 7627b0a01..40ce9459c 100644
--- a/src/Quartz/Impl/Triggers/DailyTimeIntervalTriggerImpl.cs
+++ b/src/Quartz/Impl/Triggers/DailyTimeIntervalTriggerImpl.cs
@@ -785,6 +785,22 @@ public override void SetPreviousFireTimeUtc(DateTimeOffset? previousFireTimeUtc)
             return fireTime?.ToUniversalTime();
         }
 
+        // DST fall-back correction: when the interval is added in UTC and a DST
+        // fall-back occurred, the local day has more than 24 hours. This can cause
+        // the computed fire time to remain on the same local date instead of
+        // advancing to the next day. Detect this by comparing the expected local
+        // date (from wall-clock arithmetic) with the actual computed date.
+        if (fireTime.HasValue)
+        {
+            TimeSpan utcInterval = sTime - fireTimeStartDate.ToUniversalTime();
+            DateTime expectedLocalTime = fireTimeStartDate.DateTime + utcInterval;
+
+            if (expectedLocalTime.Date != fireTime.Value.DateTime.Date)
+            {
+                fireTime = new DateTimeOffset(expectedLocalTime, TimeZoneUtil.GetUtcOffset(expectedLocalTime, TimeZone));
+            }
+        }
+
         // h. Ensure this new fireTime is within the day, or else we need to advance to next day.
         if (fireTime > fireTimeEndDate)
         {


Test patch:
diff --git a/src/Quartz.Tests.Unit/Impl/Triggers/DailyTimeIntervalTriggerImplTest.cs b/src/Quartz.Tests.Unit/Impl/Triggers/DailyTimeIntervalTriggerImplTest.cs
index 6454bda6b..dfff63deb 100644
--- a/src/Quartz.Tests.Unit/Impl/Triggers/DailyTimeIntervalTriggerImplTest.cs
+++ b/src/Quartz.Tests.Unit/Impl/Triggers/DailyTimeIntervalTriggerImplTest.cs
@@ -1241,4 +1241,184 @@ private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
     {
         public override DateTimeOffset GetUtcNow() => utcNow;
     }
+
+    [Test]
+    [Category("windowstimezoneid")]
+    public void TestDstFallBackDoesNotProduceExtraFireTimeWithin24HourInterval()
+    {
+        // Reproduces GitHub issue #1114:
+        // CET timezone, Oct 25, 2020 is a fall-back day (clocks go from
+        // CEST +02:00 to CET +01:00 at 03:00 AM local, making the day 25h long).
+        // A 24h-interval trigger starting at 00:01:05 should fire once per day,
+        // but UTC-based interval arithmetic caused an extra fire at 23:01:05 CET
+        // on the fall-back day.
+        TimeZoneInfo timeZoneInfo = TZConvert.GetTimeZoneInfo("Central European Standard Time");
+
+        IOperableTrigger trigger = (IOperableTrigger) DailyTimeIntervalScheduleBuilder.Create()
+            .StartingDailyAt(TimeOfDay.HourMinuteAndSecondOfDay(0, 1, 5))
+            .EndingDailyAt(TimeOfDay.HourMinuteAndSecondOfDay(23, 59, 59))
+            .OnEveryDay()
+            .WithIntervalInHours(24)
+            .InTimeZone(timeZoneInfo)
+            .Build();
+
+        trigger.StartTimeUtc = new DateTimeOffset(2020, 10, 24, 22, 0, 0, TimeSpan.Zero);
+        trigger.ComputeFirstFireTimeUtc(null);
+
+        DateTimeOffset from = new DateTimeOffset(2020, 10, 24, 22, 0, 0, TimeSpan.Zero);
+        DateTimeOffset to = new DateTimeOffset(2020, 10, 27, 23, 0, 0, TimeSpan.Zero);
+        IReadOnlyList<DateTimeOffset> times = TriggerUtils.ComputeFireTimesBetween(trigger, null, from, to);
+
+        Assert.That(times, Has.Count.EqualTo(3),
+            $"Expected 3 fire times but got {times.Count}: {string.Join(", ", times.Select(t => TimeZoneInfo.ConvertTime(t, timeZoneInfo)))}");
+
+        // Oct 25 00:01:05 CEST (+02:00) = Oct 24 22:01:05 UTC
+        Assert.That(times[0], Is.EqualTo(new DateTimeOffset(2020, 10, 24, 22, 1, 5, TimeSpan.Zero)));
+        // Oct 26 00:01:05 CET (+01:00) = Oct 25 23:01:05 UTC
+        Assert.That(times[1], Is.EqualTo(new DateTimeOffset(2020, 10, 25, 23, 1, 5, TimeSpan.Zero)));
+        // Oct 27 00:01:05 CET (+01:00) = Oct 26 23:01:05 UTC
+        Assert.That(times[2], Is.EqualTo(new DateTimeOffset(2020, 10, 26, 23, 1, 5, TimeSpan.Zero)));
+    }
+
+    [Test]
+    [Category("windowstimezoneid")]
+    public void TestDstFallBackWith24HourIntervalMiddayStart()
+    {
+        TimeZoneInfo timeZoneInfo = TZConvert.GetTimeZoneInfo("Central European Standard Time");
+
+        IOperableTrigger trigger = (IOperableTrigger) DailyTimeIntervalScheduleBuilder.Create()
+            .StartingDailyAt(TimeOfDay.HourAndMinuteOfDay(12, 0))
+            .EndingDailyAt(TimeOfDay.HourMinuteAndSecondOfDay(23, 59, 59))
+            .OnEveryDay()
+            .WithIntervalInHours(24)
+            .InTimeZone(timeZoneInfo)
+            .Build();
+
+        trigger.StartTimeUtc = new DateTimeOffset(2020, 10, 24, 22, 0, 0, TimeSpan.Zero);
+        trigger.ComputeFirstFireTimeUtc(null);
+
+        DateTimeOffset from = new DateTimeOffset(2020, 10, 24, 22, 0, 0, TimeSpan.Zero);
+        DateTimeOffset to = new DateTimeOffset(2020, 10, 27, 23, 0, 0, TimeSpan.Zero);
+        IReadOnlyList<DateTimeOffset> times = TriggerUtils.ComputeFireTimesBetween(trigger, null, from, to);
+
+        // Oct 25 is the fall-back day; should get one fire per day, no duplicate on Oct 25
+        Assert.That(times, Has.Count.EqualTo(3),
+            $"Expected 3 fire times but got {times.Count}: {string.Join(", ", times.Select(t => TimeZoneInfo.ConvertTime(t, timeZoneInfo)))}");
+    }
+
+    [Test]
+    [Category("windowstimezoneid")]
+    public void TestDstFallBackUsEasternDoesNotProduceExtraFireTime()
+    {
+        // US Eastern: Nov 1, 2020 is fall-back day (EDT -> EST at 02:00 AM)
+        TimeZoneInfo timeZoneInfo = TZConvert.GetTimeZoneInfo("Eastern Standard Time");
+
+        IOperableTrigger trigger = (IOperableTrigger) DailyTimeIntervalScheduleBuilder.Create()
+            .StartingDailyAt(TimeOfDay.HourAndMinuteOfDay(0, 0))
+            .EndingDailyAt(TimeOfDay.HourMinuteAndSecondOfDay(23, 59, 59))
+            .OnEveryDay()
+            .WithIntervalInHours(24)
+            .InTimeZone(timeZoneInfo)
+            .Build();
+
+        trigger.StartTimeUtc = new DateTimeOffset(2020, 11, 1, 4, 0, 0, TimeSpan.Zero);
+        trigger.ComputeFirstFireTimeUtc(null);
+
+        DateTimeOffset from = new DateTimeOffset(2020, 11, 1, 4, 0, 0, TimeSpan.Zero);
+        DateTimeOffset to = new DateTimeOffset(2020, 11, 3, 5, 0, 0, TimeSpan.Zero);
+        IReadOnlyList<DateTimeOffset> times = TriggerUtils.ComputeFireTimesBetween(trigger, null, from, to);
+
+        // Nov 1 is the fall-back day; should get one fire per day, no duplicate on Nov 1
+        Assert.That(times, Has.Count.EqualTo(3),
+            $"Expected 3 fire times but got {times.Count}: {string.Join(", ", times.Select(t => TimeZoneInfo.ConvertTime(t, timeZoneInfo)))}");
+    }
+
+    [Test]
+    [Category("windowstimezoneid")]
+    public void TestDstFallBackWithMinutesIntervalEquivalentTo24Hours()
+    {
+        // Same scenario as #1114 but using WithIntervalInMinutes(1440)
+        TimeZoneInfo timeZoneInfo = TZConvert.GetTimeZoneInfo("Central European Standard Time");
+
+        IOperableTrigger trigger = (IOperableTrigger) DailyTimeIntervalScheduleBuilder.Create()
+            .StartingDailyAt(TimeOfDay.HourMinuteAndSecondOfDay(0, 1, 5))
+            .EndingDailyAt(TimeOfDay.HourMinuteAndSecondOfDay(23, 59, 59))
+            .OnEveryDay()
+            .WithIntervalInMinutes(1440)
+            .InTimeZone(timeZoneInfo)
+            .Build();
+
+        trigger.StartTimeUtc = new DateTimeOffset(2020, 10, 24, 22, 0, 0, TimeSpan.Zero);
+        trigger.ComputeFirstFireTimeUtc(null);
+
+        DateTimeOffset from = new DateTimeOffset(2020, 10, 24, 22, 0, 0, TimeSpan.Zero);
+        DateTimeOffset to = new DateTimeOffset(2020, 10, 27, 23, 0, 0, TimeSpan.Zero);
+        IReadOnlyList<DateTimeOffset> times = TriggerUtils.ComputeFireTimesBetween(trigger, null, from, to);
+
+        Assert.That(times, Has.Count.EqualTo(3),
+            $"Expected 3 fire times but got {times.Count}: {string.Join(", ", times.Select(t => TimeZoneInfo.ConvertTime(t, timeZoneInfo)))}");
+    }
+
+    [Test]
+    [Category("windowstimezoneid")]
+    public void TestDstFallBackWithSecondsIntervalEquivalentTo24Hours()
+    {
+        // Same scenario as #1114 but using WithIntervalInSeconds(86400)
+        TimeZoneInfo timeZoneInfo = TZConvert.GetTimeZoneInfo("Central European Standard Time");
+
+        IOperableTrigger trigger = (IOperableTrigger) DailyTimeIntervalScheduleBuilder.Create()
+            .StartingDailyAt(TimeOfDay.HourMinuteAndSecondOfDay(0, 1, 5))
+            .EndingDailyAt(TimeOfDay.HourMinuteAndSecondOfDay(23, 59, 59))
+            .OnEveryDay()
+            .WithIntervalInSeconds(86400)
+            .InTimeZone(timeZoneInfo)
+            .Build();
+
+        trigger.StartTimeUtc = new DateTimeOffset(2020, 10, 24, 22, 0, 0, TimeSpan.Zero);
+        trigger.ComputeFirstFireTimeUtc(null);
+
+        DateTimeOffset from = new DateTimeOffset(2020, 10, 24, 22, 0, 0, TimeSpan.Zero);
+        DateTimeOffset to = new DateTimeOffset(2020, 10, 27, 23, 0, 0, TimeSpan.Zero);
+        IReadOnlyList<DateTimeOffset> times = TriggerUtils.ComputeFireTimesBetween(trigger, null, from, to);
+
+        Assert.That(times, Has.Count.EqualTo(3),
+            $"Expected 3 fire times but got {times.Count}: {string.Join(", ", times.Select(t => TimeZoneInfo.ConvertTime(t, timeZoneInfo)))}");
+    }
+
+    [Test]
+    [Category("windowstimezoneid")]
+    public void TestDstFallBackWithAmbiguousStartTimeOfDay()
+    {
+        // When startTimeOfDay falls in the DST ambiguous window (02:00-03:00 CET
+        // on the fall-back day), the trigger should still fire at the correct
+        // wall-clock time on subsequent days.
+        TimeZoneInfo timeZoneInfo = TZConvert.GetTimeZoneInfo("Central European Standard Time");
+
+        IOperableTrigger trigger = (IOperableTrigger) DailyTimeIntervalScheduleBuilder.Create()
+            .StartingDailyAt(TimeOfDay.HourAndMinuteOfDay(2, 30))
+            .EndingDailyAt(TimeOfDay.HourMinuteAndSecondOfDay(23, 59, 59))
+            .OnEveryDay()
+            .WithIntervalInHours(24)
+            .InTimeZone(timeZoneInfo)
+            .Build();
+
+        trigger.StartTimeUtc = new DateTimeOffset(2020, 10, 24, 0, 0, 0, TimeSpan.Zero);
+        trigger.ComputeFirstFireTimeUtc(null);
+
+        DateTimeOffset from = new DateTimeOffset(2020, 10, 24, 0, 0, 0, TimeSpan.Zero);
+        DateTimeOffset to = new DateTimeOffset(2020, 10, 27, 23, 0, 0, TimeSpan.Zero);
+        IReadOnlyList<DateTimeOffset> times = TriggerUtils.ComputeFireTimesBetween(trigger, null, from, to);
+
+        Assert.That(times, Has.Count.EqualTo(4),
+            $"Expected 4 fire times but got {times.Count}: {string.Join(", ", times.Select(t => TimeZoneInfo.ConvertTime(t, timeZoneInfo)))}");
+
+        // Oct 24 02:30 CEST (+02:00) = Oct 24 00:30 UTC
+        Assert.That(times[0], Is.EqualTo(new DateTimeOffset(2020, 10, 24, 0, 30, 0, TimeSpan.Zero)));
+        // Oct 25 02:30 CEST (+02:00) = Oct 25 00:30 UTC (first/daylight occurrence of ambiguous time)
+        Assert.That(times[1], Is.EqualTo(new DateTimeOffset(2020, 10, 25, 0, 30, 0, TimeSpan.Zero)));
+        // Oct 26 02:30 CET (+01:00) = Oct 26 01:30 UTC
+        Assert.That(times[2], Is.EqualTo(new DateTimeOffset(2020, 10, 26, 1, 30, 0, TimeSpan.Zero)));
+        // Oct 27 02:30 CET (+01:00) = Oct 27 01:30 UTC
+        Assert.That(times[3], Is.EqualTo(new DateTimeOffset(2020, 10, 27, 1, 30, 0, TimeSpan.Zero)));
+    }
 }
\ No newline at end of file