feat(worker): add NextDueCalculator with workday + catch-up logic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-28 08:59:19 +02:00
parent f383645360
commit 975db8ab54
2 changed files with 157 additions and 0 deletions

View File

@@ -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<PrimeScheduleDto> 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);
}

View File

@@ -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);
}
}