feat(worker): compute prime due-time from weekday bitmask
Also fixes PrimeScheduleRepository.ListAsync to sort client-side (SQLite EF Core does not support TimeSpan in ORDER BY clauses).
This commit is contained in:
@@ -12,10 +12,8 @@ public sealed class PrimeScheduleRepository
|
||||
|
||||
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _context.PrimeSchedules.AsNoTracking()
|
||||
.OrderBy(s => s.TimeOfDay)
|
||||
.ToListAsync(ct);
|
||||
return rows;
|
||||
var rows = await _context.PrimeSchedules.AsNoTracking().ToListAsync(ct);
|
||||
return rows.OrderBy(s => s.TimeOfDay).ToList();
|
||||
}
|
||||
|
||||
public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately);
|
||||
@@ -22,30 +24,24 @@ public static class NextDueCalculator
|
||||
|
||||
private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp)
|
||||
{
|
||||
if (s.EndDate < DateOnly.FromDateTime(now.LocalDateTime)) return null;
|
||||
if ((PrimeDays)s.Days == PrimeDays.None) return null;
|
||||
|
||||
var todayLocal = DateOnly.FromDateTime(now.LocalDateTime);
|
||||
var alreadyFiredToday = s.LastRunAt is { } last &&
|
||||
DateOnly.FromDateTime(last.LocalDateTime) == todayLocal;
|
||||
|
||||
if (!alreadyFiredToday)
|
||||
if (!alreadyFiredToday && IsEligibleDay(s, todayLocal))
|
||||
{
|
||||
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 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++)
|
||||
for (int i = 0; i < 7; 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);
|
||||
@@ -53,13 +49,20 @@ public static class NextDueCalculator
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d)
|
||||
private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) =>
|
||||
((PrimeDays)s.Days & ToFlag(d.DayOfWeek)) != PrimeDays.None;
|
||||
|
||||
private static PrimeDays ToFlag(DayOfWeek dow) => dow switch
|
||||
{
|
||||
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;
|
||||
}
|
||||
DayOfWeek.Monday => PrimeDays.Monday,
|
||||
DayOfWeek.Tuesday => PrimeDays.Tuesday,
|
||||
DayOfWeek.Wednesday => PrimeDays.Wednesday,
|
||||
DayOfWeek.Thursday => PrimeDays.Thursday,
|
||||
DayOfWeek.Friday => PrimeDays.Friday,
|
||||
DayOfWeek.Saturday => PrimeDays.Saturday,
|
||||
DayOfWeek.Sunday => PrimeDays.Sunday,
|
||||
_ => PrimeDays.None,
|
||||
};
|
||||
|
||||
private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) =>
|
||||
new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset);
|
||||
|
||||
@@ -2,10 +2,8 @@ namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed record PrimeScheduleDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
int Days,
|
||||
TimeSpan TimeOfDay,
|
||||
bool WorkdaysOnly,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastRunAt,
|
||||
string? PromptOverride);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Prime;
|
||||
@@ -5,26 +6,34 @@ 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);
|
||||
PrimeDays days, TimeSpan time,
|
||||
bool enabled = true, DateTimeOffset? lastRun = null) =>
|
||||
new(Guid.NewGuid(), (int)days, time, 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)));
|
||||
var s = Schedule(PrimeDays.All, new(7, 0, 0), enabled: false);
|
||||
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_Days_Selected_Returns_Null()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
|
||||
var s = Schedule(PrimeDays.None, new(7, 0, 0));
|
||||
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));
|
||||
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); // Tue
|
||||
var s = Schedule(PrimeDays.All, 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.Equal(new DateTimeOffset(2026, 5, 5, 7, 0, 0, now.Offset), r!.At);
|
||||
Assert.False(r.FireImmediately);
|
||||
}
|
||||
|
||||
@@ -32,8 +41,8 @@ public class NextDueCalculatorTests
|
||||
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));
|
||||
var s = Schedule(PrimeDays.All, new(7, 0, 0));
|
||||
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
|
||||
Assert.NotNull(r);
|
||||
Assert.True(r!.FireImmediately);
|
||||
}
|
||||
@@ -41,50 +50,53 @@ public class NextDueCalculatorTests
|
||||
[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));
|
||||
var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); // Tue
|
||||
var s = Schedule(PrimeDays.All, 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));
|
||||
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkdaysOnly_Skips_Weekend()
|
||||
public void Weekdays_Only_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));
|
||||
var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); // Fri, past catch-up
|
||||
var s = Schedule(PrimeDays.Weekdays, new(7, 0, 0));
|
||||
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));
|
||||
Assert.Equal(new DateOnly(2026, 5, 11), DateOnly.FromDateTime(r.At.LocalDateTime));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Already_Fired_Today_Skips_To_Tomorrow()
|
||||
public void Single_Day_Schedule_Targets_That_Weekday()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 5, 5, 8, 0, 0, TimeSpan.FromHours(2)); // Tue, past catch-up
|
||||
var s = Schedule(PrimeDays.Friday, new(7, 0, 0));
|
||||
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal(DayOfWeek.Friday, r!.At.LocalDateTime.DayOfWeek);
|
||||
Assert.Equal(new DateOnly(2026, 5, 8), DateOnly.FromDateTime(r.At.LocalDateTime));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Already_Fired_Today_Skips_To_Next_Eligible_Day()
|
||||
{
|
||||
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));
|
||||
var s = Schedule(PrimeDays.All, 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)));
|
||||
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
|
||||
}
|
||||
|
||||
[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));
|
||||
var early = Schedule(PrimeDays.All, new(7, 0, 0));
|
||||
var late = Schedule(PrimeDays.All, 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user