feat: Adds yearly calendared events with recurrences (#2177)
This commit is contained in:
parent
4beda6a29d
commit
f02403a374
6 changed files with 232 additions and 55 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
66
Projects/UOContent/Engines/Events/MonthDay.cs
Normal file
66
Projects/UOContent/Engines/Events/MonthDay.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
60
Projects/UOContent/Engines/Events/ScheduledEvent.cs
Normal file
60
Projects/UOContent/Engines/Events/ScheduledEvent.cs
Normal 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
71
Projects/UOContent/Engines/Events/YearlyScheduledEvent.cs
Normal file
71
Projects/UOContent/Engines/Events/YearlyScheduledEvent.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue