diff --git a/src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs b/src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs index b7be276..05d980f 100644 --- a/src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs +++ b/src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs @@ -12,10 +12,8 @@ public sealed class PrimeScheduleRepository public async Task> 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 GetAsync(Guid id, CancellationToken ct = default) => diff --git a/src/ClaudeDo.Worker/Prime/NextDueCalculator.cs b/src/ClaudeDo.Worker/Prime/NextDueCalculator.cs index 16100eb..128a88e 100644 --- a/src/ClaudeDo.Worker/Prime/NextDueCalculator.cs +++ b/src/ClaudeDo.Worker/Prime/NextDueCalculator.cs @@ -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); diff --git a/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs b/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs index 6f0bd4b..ec13825 100644 --- a/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs +++ b/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs @@ -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); diff --git a/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs index ccfa3e5..0a06415 100644 --- a/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs @@ -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); }