feat: Adds yearly calendared events with recurrences (#2177)

This commit is contained in:
Kamron Batman 2025-05-06 23:52:49 -07:00 committed by GitHub
parent 4beda6a29d
commit f02403a374
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 232 additions and 55 deletions

View file

@ -81,10 +81,11 @@ public class EventSchedulerTests
() => called = true
);
Assert.Equal(Core._now, evt.NextOccurrence);
Timer.Slice(8);
Assert.True(called);
Assert.Equal(Core._now, evt.NextOccurrence);
Assert.Equal(DateTime.MaxValue, evt.NextOccurrence);
evt.Cancel();
}

View file

@ -31,60 +31,6 @@ public enum DaysOfWeek : byte
EveryDay = Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday
}
public abstract class ScheduledEvent
{
public IRecurrencePattern Recurrence { get; }
public TimeZoneInfo TimeZone { get; }
public TimeOnly Time { get; }
public DateTime EndDate { get; }
public DateTime NextOccurrence { get; private set; }
public bool Cancelled { get; private set; }
public ScheduledEvent(DateTime startOn, TimeZoneInfo timeZone = null)
: this(startOn, startOn, TimeOnly.FromDateTime(startOn), null, timeZone)
{
}
public ScheduledEvent(DateTime startAfter, TimeOnly time, IRecurrencePattern recurrence, TimeZoneInfo timeZone = null)
: this(startAfter, DateTime.MaxValue, time, recurrence, timeZone)
{
}
public ScheduledEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time,
IRecurrencePattern recurrence,
TimeZoneInfo timeZone = null
)
{
Time = time;
Recurrence = recurrence;
TimeZone = timeZone ?? TimeZoneInfo.Utc;
var afterUtc = startAfter.Kind == DateTimeKind.Utc ? startAfter : startAfter.LocalToUtc(TimeZone);
NextOccurrence = recurrence?.GetNextOccurrence(afterUtc, time, TimeZone) ?? afterUtc;
EndDate = endOn == DateTime.MaxValue || endOn.Kind == DateTimeKind.Utc ? endOn : endOn.LocalToUtc(TimeZone);
}
public void Cancel() => Cancelled = true;
public DateTime Advance()
{
OnEvent();
var next = Recurrence?.GetNextOccurrence(NextOccurrence, Time, TimeZone) ?? DateTime.MaxValue;
if (next == DateTime.MaxValue || next > EndDate)
{
return DateTime.MaxValue;
}
return NextOccurrence = next;
}
public abstract void OnEvent();
}
public class EventScheduler : Timer
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(EventScheduler));

View file

@ -0,0 +1,66 @@
using System;
namespace Server.Engines.Events;
public record struct MonthDay : IComparable<MonthDay>
{
public byte Month { get; }
public byte Day { get; }
public MonthDay(int year, int month, int day)
{
if (month is < 1 or > 12)
{
throw new ArgumentOutOfRangeException(nameof(month), "Month must be between 1 and 12.");
}
var daysInMonth = DateTime.DaysInMonth(year, month);
if (day < 1 || day > daysInMonth)
{
throw new ArgumentOutOfRangeException(nameof(day), $"Day must be between 1 and {daysInMonth} for month {month}.");
}
Month = (byte)month;
Day = (byte)day;
}
public int CompareTo(MonthDay other)
{
var monthComparison = Month.CompareTo(other.Month);
return monthComparison != 0 ? monthComparison : Day.CompareTo(other.Day);
}
public static bool operator <(MonthDay left, MonthDay right) => left.CompareTo(right) < 0;
public static bool operator >(MonthDay left, MonthDay right) => left.CompareTo(right) > 0;
public static bool operator <=(MonthDay left, MonthDay right) => left.CompareTo(right) <= 0;
public static bool operator >=(MonthDay left, MonthDay right) => left.CompareTo(right) >= 0;
}
public static class MonthDayExtensions
{
public static bool IsBetween(this DateTime dateTime, MonthDay start, MonthDay end)
{
var checkMonth = dateTime.Month;
var checkDay = dateTime.Day;
var startMonth = start.Month;
var startDay = start.Day;
var endMonth = end.Month;
var endDay = end.Day;
var isAfterStart = checkMonth > startMonth || checkMonth == startMonth && checkDay >= startDay;
var isBeforeEnd = checkMonth < endMonth || checkMonth == endMonth && checkDay <= endDay;
if (startMonth < endMonth || startMonth == endMonth && startDay <= endDay)
{
return isAfterStart && isBeforeEnd;
}
// Complex case: spans year boundary (e.g., Nov 15 - Feb 15)
return isAfterStart || isBeforeEnd;
}
}

View file

@ -0,0 +1,60 @@
using System;
namespace Server.Engines.Events;
public abstract class ScheduledEvent
{
public IRecurrencePattern Recurrence { get; }
public TimeZoneInfo TimeZone { get; }
public TimeOnly Time { get; }
public DateTime EndDate { get; }
public DateTime NextOccurrence { get; protected set; }
public bool Cancelled { get; private set; }
public ScheduledEvent(DateTime startOn, TimeZoneInfo timeZone = null)
: this(startOn, startOn, TimeOnly.FromDateTime(startOn), null, timeZone)
{
}
public ScheduledEvent(DateTime startAfter, TimeOnly time, IRecurrencePattern recurrence, TimeZoneInfo timeZone = null)
: this(startAfter, DateTime.MaxValue, time, recurrence, timeZone)
{
}
public ScheduledEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time,
IRecurrencePattern recurrence,
TimeZoneInfo timeZone = null
)
{
Time = time;
Recurrence = recurrence;
TimeZone = timeZone ?? TimeZoneInfo.Utc;
EndDate = endOn == DateTime.MaxValue || endOn.Kind == DateTimeKind.Utc ? endOn : endOn.LocalToUtc(TimeZone);
var afterUtc = startAfter.Kind == DateTimeKind.Utc ? startAfter : startAfter.LocalToUtc(TimeZone);
var next = GetOccurrence(afterUtc);
// For the first occurrence, we should set it to the startAfter date if we have no recurrence.
NextOccurrence = next == DateTime.MaxValue ? afterUtc : next;
}
protected virtual DateTime GetOccurrence(DateTime after)
{
var next = Recurrence?.GetNextOccurrence(after, Time, TimeZone) ?? DateTime.MaxValue;
return next >= EndDate ? DateTime.MaxValue : next;
}
public void Cancel() => Cancelled = true;
public virtual DateTime Advance()
{
OnEvent();
return NextOccurrence = GetOccurrence(NextOccurrence);
}
public abstract void OnEvent();
}

View file

@ -0,0 +1,33 @@
using System;
namespace Server.Engines.Events;
public class YearlyCallbackScheduledEvent : YearlyScheduledEvent
{
private readonly Action _callback;
protected YearlyCallbackScheduledEvent(
DateTime startAfter,
TimeOnly time,
MonthDay yearlyStart,
MonthDay yearlyEnd,
Action callback,
IRecurrencePattern recurrence,
TimeZoneInfo timeZone = null
) : this(startAfter, DateTime.MaxValue, time, yearlyStart, yearlyEnd, callback, recurrence, timeZone)
{
}
protected YearlyCallbackScheduledEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time,
MonthDay yearlyStart,
MonthDay yearlyEnd,
Action callback,
IRecurrencePattern recurrence,
TimeZoneInfo timeZone = null
) : base(startAfter, endOn, time, yearlyStart, yearlyEnd, recurrence, timeZone) => _callback = callback;
public override void OnEvent() => _callback();
}

View file

@ -0,0 +1,71 @@
using System;
namespace Server.Engines.Events;
public abstract class YearlyScheduledEvent : ScheduledEvent
{
public MonthDay YearlyStart { get; }
public MonthDay YearlyEnd { get; }
protected YearlyScheduledEvent(
DateTime startAfter,
TimeOnly time,
MonthDay yearlyStart,
MonthDay yearlyEnd,
IRecurrencePattern recurrence,
TimeZoneInfo timeZone = null
) : this(startAfter, DateTime.MaxValue, time, yearlyStart, yearlyEnd, recurrence, timeZone)
{
}
protected YearlyScheduledEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time,
MonthDay yearlyStart,
MonthDay yearlyEnd,
IRecurrencePattern recurrence,
TimeZoneInfo timeZone = null
) : base(startAfter, endOn, time, recurrence, timeZone)
{
YearlyStart = yearlyStart;
YearlyEnd = yearlyEnd;
}
protected override DateTime GetOccurrence(DateTime after)
{
var next = base.GetOccurrence(after);
if (next == DateTime.MaxValue)
{
return DateTime.MaxValue;
}
var localNext = TimeZoneInfo.ConvertTimeFromUtc(next, TimeZone);
if (localNext.IsBetween(YearlyStart, YearlyEnd))
{
return next;
}
int yearToUse;
// If we're after the end of this year's range but before the start of next year's range
if (YearlyStart > YearlyEnd && localNext.Month > YearlyEnd.Month)
{
// We're in the same calendar year, targeting this year's start
yearToUse = localNext.Year;
}
else
{
// Either we're in a non-spanning range, or we're in the early part of next year
// In either case, we need to advance to the next year's start
yearToUse = localNext.Year + 1;
}
var nextYearStartUtc = new DateTime(yearToUse, YearlyStart.Month, YearlyStart.Day).LocalToUtc(TimeZone);
return Recurrence!.GetNextOccurrence(nextYearStartUtc, Time, TimeZone);
}
}