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:
mika kuns
2026-06-02 16:32:51 +02:00
parent dff06d9e35
commit bed4255a5e
4 changed files with 73 additions and 62 deletions

View File

@@ -12,10 +12,8 @@ public sealed class PrimeScheduleRepository
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default) public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
{ {
var rows = await _context.PrimeSchedules.AsNoTracking() var rows = await _context.PrimeSchedules.AsNoTracking().ToListAsync(ct);
.OrderBy(s => s.TimeOfDay) return rows.OrderBy(s => s.TimeOfDay).ToList();
.ToListAsync(ct);
return rows;
} }
public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) => public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) =>

View File

@@ -1,3 +1,5 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Prime; namespace ClaudeDo.Worker.Prime;
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately); 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) 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 todayLocal = DateOnly.FromDateTime(now.LocalDateTime);
var alreadyFiredToday = s.LastRunAt is { } last && var alreadyFiredToday = s.LastRunAt is { } last &&
DateOnly.FromDateTime(last.LocalDateTime) == todayLocal; DateOnly.FromDateTime(last.LocalDateTime) == todayLocal;
if (!alreadyFiredToday) if (!alreadyFiredToday && IsEligibleDay(s, todayLocal))
{ {
var startOrToday = s.StartDate > todayLocal ? s.StartDate : todayLocal; var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset);
if (startOrToday == todayLocal && IsEligibleDay(s, todayLocal)) if (todayTarget >= now)
{ return new NextDue(s, todayTarget, false);
var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset); if (now <= todayTarget + catchUp)
if (todayTarget >= now) return new NextDue(s, now, true);
return new NextDue(s, todayTarget, false);
if (now <= todayTarget + catchUp)
return new NextDue(s, now, true);
}
} }
var d = todayLocal.AddDays(1); var d = todayLocal.AddDays(1);
if (s.StartDate > d) d = s.StartDate; for (int i = 0; i < 7; i++)
for (int i = 0; i < 8; i++)
{ {
if (d > s.EndDate) return null;
if (IsEligibleDay(s, d)) if (IsEligibleDay(s, d))
return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false); return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false);
d = d.AddDays(1); d = d.AddDays(1);
@@ -53,13 +49,20 @@ public static class NextDueCalculator
return null; 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; DayOfWeek.Monday => PrimeDays.Monday,
if (!s.WorkdaysOnly) return true; DayOfWeek.Tuesday => PrimeDays.Tuesday,
var dow = d.ToDateTime(TimeOnly.MinValue).DayOfWeek; DayOfWeek.Wednesday => PrimeDays.Wednesday,
return dow != DayOfWeek.Saturday && dow != DayOfWeek.Sunday; 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) => private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) =>
new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset); new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset);

View File

@@ -2,10 +2,8 @@ namespace ClaudeDo.Worker.Prime;
public sealed record PrimeScheduleDto( public sealed record PrimeScheduleDto(
Guid Id, Guid Id,
DateOnly StartDate, int Days,
DateOnly EndDate,
TimeSpan TimeOfDay, TimeSpan TimeOfDay,
bool WorkdaysOnly,
bool Enabled, bool Enabled,
DateTimeOffset? LastRunAt, DateTimeOffset? LastRunAt,
string? PromptOverride); string? PromptOverride);

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Prime;
namespace ClaudeDo.Worker.Tests.Prime; namespace ClaudeDo.Worker.Tests.Prime;
@@ -5,26 +6,34 @@ namespace ClaudeDo.Worker.Tests.Prime;
public class NextDueCalculatorTests public class NextDueCalculatorTests
{ {
private static PrimeScheduleDto Schedule( private static PrimeScheduleDto Schedule(
DateOnly start, DateOnly end, TimeSpan time, PrimeDays days, TimeSpan time,
bool workdaysOnly = true, bool enabled = true, DateTimeOffset? lastRun = null) => bool enabled = true, DateTimeOffset? lastRun = null) =>
new(Guid.NewGuid(), start, end, time, workdaysOnly, enabled, lastRun, null); new(Guid.NewGuid(), (int)days, time, enabled, lastRun, null);
[Fact] [Fact]
public void Disabled_Schedule_Returns_Null() public void Disabled_Schedule_Returns_Null()
{ {
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); 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); var s = Schedule(PrimeDays.All, new(7, 0, 0), enabled: false);
Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); 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] [Fact]
public void Future_Same_Day_Returns_Today_At_Target() public void Future_Same_Day_Returns_Today_At_Target()
{ {
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); 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); Assert.False(r.FireImmediately);
} }
@@ -32,8 +41,8 @@ public class NextDueCalculatorTests
public void Within_CatchUp_Window_Fires_Immediately() public void Within_CatchUp_Window_Fires_Immediately()
{ {
var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2)); 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 s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); Assert.NotNull(r);
Assert.True(r!.FireImmediately); Assert.True(r!.FireImmediately);
} }
@@ -41,50 +50,53 @@ public class NextDueCalculatorTests
[Fact] [Fact]
public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day() public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day()
{ {
var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); 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] [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 now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); // Fri, past catch-up
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), workdaysOnly: true); var s = Schedule(PrimeDays.Weekdays, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); Assert.NotNull(r);
Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek); 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] [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 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 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 s = Schedule(PrimeDays.All, new(7, 0, 0), lastRun: lastRun);
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); 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 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] [Fact]
public void Multiple_Schedules_Returns_Earliest() public void Multiple_Schedules_Returns_Earliest()
{ {
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); 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 early = Schedule(PrimeDays.All, new(7, 0, 0));
var late = Schedule(new(2026,5,1), new(2026,5,31), new(9,0,0)); var late = Schedule(PrimeDays.All, new(9, 0, 0));
var r = NextDueCalculator.Compute(new[]{late, early}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { late, early }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); Assert.NotNull(r);
Assert.Equal(early.Id, r!.Schedule.Id); Assert.Equal(early.Id, r!.Schedule.Id);
} }