From 975db8ab5453d2a27cd76b64e0b5ac9e2c154980 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 28 Apr 2026 08:59:19 +0200 Subject: [PATCH] feat(worker): add NextDueCalculator with workday + catch-up logic Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Prime/NextDueCalculator.cs | 66 ++++++++++++++ .../Prime/NextDueCalculatorTests.cs | 91 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/ClaudeDo.Worker/Prime/NextDueCalculator.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs diff --git a/src/ClaudeDo.Worker/Prime/NextDueCalculator.cs b/src/ClaudeDo.Worker/Prime/NextDueCalculator.cs new file mode 100644 index 0000000..16100eb --- /dev/null +++ b/src/ClaudeDo.Worker/Prime/NextDueCalculator.cs @@ -0,0 +1,66 @@ +namespace ClaudeDo.Worker.Prime; + +public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately); + +public static class NextDueCalculator +{ + public static NextDue? Compute( + IEnumerable schedules, + DateTimeOffset now, + TimeSpan catchUp) + { + NextDue? best = null; + foreach (var s in schedules) + { + if (!s.Enabled) continue; + var due = ComputeFor(s, now, catchUp); + if (due is null) continue; + if (best is null || due.At < best.At) best = due; + } + return best; + } + + private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp) + { + if (s.EndDate < DateOnly.FromDateTime(now.LocalDateTime)) return null; + + var todayLocal = DateOnly.FromDateTime(now.LocalDateTime); + var alreadyFiredToday = s.LastRunAt is { } last && + DateOnly.FromDateTime(last.LocalDateTime) == todayLocal; + + if (!alreadyFiredToday) + { + var startOrToday = s.StartDate > todayLocal ? s.StartDate : todayLocal; + if (startOrToday == todayLocal && IsEligibleDay(s, todayLocal)) + { + var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset); + if (todayTarget >= now) + return new NextDue(s, todayTarget, false); + if (now <= todayTarget + catchUp) + return new NextDue(s, now, true); + } + } + + var d = todayLocal.AddDays(1); + if (s.StartDate > d) d = s.StartDate; + for (int i = 0; i < 8; i++) + { + if (d > s.EndDate) return null; + if (IsEligibleDay(s, d)) + return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false); + d = d.AddDays(1); + } + return null; + } + + private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) + { + if (d < s.StartDate || d > s.EndDate) return false; + if (!s.WorkdaysOnly) return true; + var dow = d.ToDateTime(TimeOnly.MinValue).DayOfWeek; + return dow != DayOfWeek.Saturday && dow != DayOfWeek.Sunday; + } + + private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) => + new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset); +} diff --git a/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs new file mode 100644 index 0000000..ccfa3e5 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs @@ -0,0 +1,91 @@ +using ClaudeDo.Worker.Prime; + +namespace ClaudeDo.Worker.Tests.Prime; + +public class NextDueCalculatorTests +{ + private static PrimeScheduleDto Schedule( + DateOnly start, DateOnly end, TimeSpan time, + bool workdaysOnly = true, bool enabled = true, DateTimeOffset? lastRun = null) => + new(Guid.NewGuid(), start, end, time, workdaysOnly, enabled, lastRun, null); + + [Fact] + public void Disabled_Schedule_Returns_Null() + { + var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), enabled: false); + Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); + } + + [Fact] + public void Future_Same_Day_Returns_Today_At_Target() + { + var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.Equal(new DateTimeOffset(2026,5,5,7,0,0, now.Offset), r!.At); + Assert.False(r.FireImmediately); + } + + [Fact] + public void Within_CatchUp_Window_Fires_Immediately() + { + var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.True(r!.FireImmediately); + } + + [Fact] + public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day() + { + var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); + } + + [Fact] + public void WorkdaysOnly_Skips_Weekend() + { + var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), workdaysOnly: true); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek); + Assert.Equal(new DateOnly(2026,5,11), DateOnly.FromDateTime(r.At.LocalDateTime)); + } + + [Fact] + public void Already_Fired_Today_Skips_To_Tomorrow() + { + var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); + var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), lastRun: lastRun); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); + } + + [Fact] + public void Past_EndDate_Returns_Null() + { + var now = new DateTimeOffset(2026, 6, 1, 6, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); + } + + [Fact] + public void Multiple_Schedules_Returns_Earliest() + { + var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); + var early = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + var late = Schedule(new(2026,5,1), new(2026,5,31), new(9,0,0)); + var r = NextDueCalculator.Compute(new[]{late, early}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.Equal(early.Id, r!.Schedule.Id); + } +}