37 KiB
Prime Recurring Weekday Schedule — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the Prime schedule's date-range model with a recurring weekday model — pick a set of weekdays plus a time, and the ping fires on the next eligible day the worker is running.
Architecture: A [Flags] PrimeDays weekday bitmask stored as a single days_of_week int column replaces StartDate/EndDate/WorkdaysOnly. NextDueCalculator walks forward to the next selected weekday; the existing 30-minute catch-up and already-fired-today logic are untouched. UI swaps the range picker + Mon–Fri checkbox for seven toggle buttons. Both SignalR DTO copies carry a single int Days.
Tech Stack: .NET 8, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), SignalR, xUnit.
Spec: docs/superpowers/specs/2026-06-02-prime-recurring-weekdays-design.md
Build/test note: dotnet build ClaudeDo.slnx needs .NET 9; on .NET 8 build individual csproj. Commands in this plan use the per-project form.
File Structure
src/ClaudeDo.Data/Models/PrimeDays.cs— new,[Flags]enum.src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs— swap fields.src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs— column mapping.src/ClaudeDo.Data/Migrations/*— new migration + snapshot.src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs— upsert fields + ordering.src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs—int Days.src/ClaudeDo.Worker/Prime/NextDueCalculator.cs— weekday eligibility.src/ClaudeDo.Worker/Prime/PrimeScheduler.cs—ToDtomapping.src/ClaudeDo.Worker/Hub/WorkerHub.cs— list/upsert mapping.src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs—int Days.src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs— 7 day bools.src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs— defaults + validation.src/ClaudeDo.Ui/Design/IslandStyles.axaml—day-togglestyle class.src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml— row template.- Tests:
NextDueCalculatorTests,PrimeSchedulerTests,PrimeScheduleRepositoryTests,PrimeClaudeTabViewModelTests. - Docs:
src/ClaudeDo.Data/CLAUDE.md, rootCLAUDE.md.
Task 1: PrimeDays enum + entity + configuration
Files:
-
Create:
src/ClaudeDo.Data/Models/PrimeDays.cs -
Modify:
src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs -
Modify:
src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs -
Step 1: Create the flags enum
src/ClaudeDo.Data/Models/PrimeDays.cs:
namespace ClaudeDo.Data.Models;
[Flags]
public enum PrimeDays
{
None = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 4,
Thursday = 8,
Friday = 16,
Saturday = 32,
Sunday = 64,
Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday, // 31
All = Weekdays | Saturday | Sunday, // 127
}
- Step 2: Swap entity fields
In src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs, remove StartDate, EndDate, WorkdaysOnly and add Days. Result:
namespace ClaudeDo.Data.Models;
public sealed class PrimeScheduleEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public PrimeDays Days { get; set; } = PrimeDays.Weekdays;
public TimeSpan TimeOfDay { get; set; }
public bool Enabled { get; set; } = true;
public DateTimeOffset? LastRunAt { get; set; }
public string? PromptOverride { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
- Step 3: Update entity configuration
In src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs, replace the start_date/end_date/workdays_only property lines with a days_of_week mapping (EF maps the enum to INTEGER automatically):
builder.Property(s => s.Days).HasColumnName("days_of_week")
.IsRequired().HasDefaultValue(PrimeDays.Weekdays);
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
Leave Id, LastRunAt, PromptOverride, CreatedAt mappings unchanged. Add using ClaudeDo.Data.Models; if not present (it already is).
- Step 4: Build the Data project
Run: dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
Expected: FAILS — PrimeScheduleRepository, snapshot, etc. still reference removed fields. That is expected; Tasks 2–3 fix it. (If you prefer a clean build gate, proceed to Task 2 before building.)
- Step 5: Commit
git add src/ClaudeDo.Data/Models/PrimeDays.cs src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs
git commit -m "feat(data): model Prime schedule as weekday bitmask"
Task 2: Repository
Files:
-
Modify:
src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs -
Step 1: Update
ListAsyncordering
The old ordering used StartDate. Order by TimeOfDay:
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
{
var rows = await _context.PrimeSchedules.AsNoTracking()
.OrderBy(s => s.TimeOfDay)
.ToListAsync(ct);
return rows;
}
- Step 2: Update
UpsertAsyncfield copy
Replace the three removed-field assignments with Days:
else
{
existing.Days = entity.Days;
existing.TimeOfDay = entity.TimeOfDay;
existing.Enabled = entity.Enabled;
existing.PromptOverride = entity.PromptOverride;
}
Leave GetAsync, DeleteAsync, UpdateLastRunAsync unchanged.
- Step 3: Commit (build verified after migration in Task 3)
git add src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
git commit -m "feat(data): persist weekday bitmask in prime schedule repo"
Task 3: EF migration
Files:
-
Create:
src/ClaudeDo.Data/Migrations/<timestamp>_PrimeWeekdays.cs(generated) -
Modify:
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs(generated) -
Step 1: Generate the migration
Run from repo root:
dotnet ef migrations add PrimeWeekdays --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
Expected: a new *_PrimeWeekdays.cs file and an updated snapshot. (If dotnet ef is unavailable, hand-write the migration using the body below.)
- Step 2: Replace the generated
Upbody with an explicit backfill
EF's auto-generated drop/add would discard existing schedules' weekday intent. Edit the new migration's Up to add the column, backfill from workdays_only, then drop the old columns:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "days_of_week",
table: "prime_schedules",
type: "INTEGER",
nullable: false,
defaultValue: 31);
migrationBuilder.Sql(
"UPDATE prime_schedules SET days_of_week = CASE WHEN workdays_only = 1 THEN 31 ELSE 127 END;");
migrationBuilder.DropColumn(name: "start_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "end_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "workdays_only", table: "prime_schedules");
}
- Step 3: Replace the generated
Downbody
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "start_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2000, 1, 1));
migrationBuilder.AddColumn<DateOnly>(
name: "end_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2099, 12, 31));
migrationBuilder.AddColumn<bool>(
name: "workdays_only", table: "prime_schedules",
type: "INTEGER", nullable: false, defaultValue: true);
migrationBuilder.Sql(
"UPDATE prime_schedules SET workdays_only = CASE WHEN days_of_week = 127 THEN 0 ELSE 1 END;");
migrationBuilder.DropColumn(name: "days_of_week", table: "prime_schedules");
}
Add using System; at the top of the migration file if DateOnly defaults require it (the existing AddPrimeSchedules migration already imports System).
- Step 4: Build the Data project
Run: dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
Expected: PASS.
- Step 5: Commit
git add src/ClaudeDo.Data/Migrations
git commit -m "feat(data): migrate prime schedules to days_of_week bitmask"
Task 4: Worker DTO + NextDueCalculator (TDD)
Files:
-
Modify:
src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs -
Modify:
src/ClaudeDo.Worker/Prime/NextDueCalculator.cs -
Test:
tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs -
Step 1: Update the Worker DTO
src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs:
namespace ClaudeDo.Worker.Prime;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
- Step 2: Rewrite the calculator tests
Replace the entire body of tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs. Note: 2026-05-05 is a Tuesday; 2026-05-08 is a Friday; 2026-05-09/10 are Sat/Sun; 2026-05-11 is a Monday.
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime;
namespace ClaudeDo.Worker.Tests.Prime;
public class NextDueCalculatorTests
{
private static PrimeScheduleDto Schedule(
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(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)); // 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.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(PrimeDays.All, 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)); // 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));
}
[Fact]
public void Weekdays_Only_Skips_Weekend()
{
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));
}
[Fact]
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(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 Multiple_Schedules_Returns_Earliest()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
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);
}
}
- Step 3: Run the tests to verify they fail
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests
Expected: FAIL — PrimeScheduleDto no longer has StartDate/EndDate/workdaysOnly, and the calculator still references them (compile errors).
- Step 4: Rewrite the calculator
Replace the entire body of src/ClaudeDo.Worker/Prime/NextDueCalculator.cs:
using ClaudeDo.Data.Models;
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 ((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 && 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);
for (int i = 0; i < 7; i++)
{
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) =>
((PrimeDays)s.Days & ToFlag(d.DayOfWeek)) != PrimeDays.None;
private static PrimeDays ToFlag(DayOfWeek dow) => dow switch
{
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);
}
- Step 5: Run the calculator tests
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests
Expected: still FAILS to build — PrimeScheduler.ToDto and WorkerHub mappings reference removed fields. Proceed to Tasks 5–6, then re-run.
- Step 6: Commit
git add src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs src/ClaudeDo.Worker/Prime/NextDueCalculator.cs tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs
git commit -m "feat(worker): compute prime due-time from weekday bitmask"
Task 5: PrimeScheduler.ToDto + scheduler tests
Files:
-
Modify:
src/ClaudeDo.Worker/Prime/PrimeScheduler.cs:104-105 -
Test:
tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs -
Step 1: Update the
ToDtomapping
Replace the ToDto method in PrimeScheduler.cs:
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride);
- Step 2: Update scheduler test fixtures
In tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs, every new PrimeScheduleEntity { ... } initializer sets StartDate/EndDate/WorkdaysOnly. Replace those three lines in each of the three initializers (lines ~48-52, ~89-94, ~131-136) with a single Days assignment. Each initializer becomes:
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.All,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
Add using ClaudeDo.Data.Models; to the file's usings if not already present (it is, via line 1).
- Step 3: Run scheduler + calculator tests
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~Prime"
Expected: still build-fails until WorkerHub (Task 6) compiles. After Task 6, this command must PASS.
- Step 4: Commit
git add src/ClaudeDo.Worker/Prime/PrimeScheduler.cs tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs
git commit -m "test(worker): adapt prime scheduler tests to weekday model"
Task 6: WorkerHub mapping + repository tests
Files:
-
Modify:
src/ClaudeDo.Worker/Hub/WorkerHub.cs:488-518 -
Test:
tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs -
Step 1: Update
ListPrimeSchedules
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
{
using var ctx = _dbFactory.CreateDbContext();
var rows = await new PrimeScheduleRepository(ctx).ListAsync();
return rows.Select(e => new PrimeScheduleDto(
e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
}
- Step 2: Update
UpsertPrimeSchedule
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new PrimeScheduleRepository(ctx);
var existing = await repo.GetAsync(dto.Id);
var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
{
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
TimeOfDay = dto.TimeOfDay,
Enabled = dto.Enabled,
PromptOverride = dto.PromptOverride,
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
LastRunAt = existing?.LastRunAt,
};
await repo.UpsertAsync(entity);
_primeSignal.Signal();
return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
entity.Enabled, entity.LastRunAt, entity.PromptOverride);
}
DeletePrimeSchedule is unchanged.
- Step 3: Update repository tests
In tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs, replace each entity initializer's StartDate/EndDate/WorkdaysOnly lines with Days = PrimeDays.Weekdays, (drop them where only StartDate/EndDate appear). The three initializers become:
// Upsert_Then_List_RoundTrips
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.Weekdays,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
// UpdateLastRunAt_Persists
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.Weekdays,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
// Delete_Removes_Row
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.All,
TimeOfDay = TimeSpan.Zero,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
Add an assertion in Upsert_Then_List_RoundTrips after the existing time assertion:
Assert.Equal(PrimeDays.Weekdays, rows[0].Days);
- Step 4: Build worker + run all worker tests
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet test tests/ClaudeDo.Worker.Tests
Expected: PASS (all Prime + repository tests green).
- Step 5: Commit
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs
git commit -m "feat(worker): map prime schedule weekday bitmask over the hub"
Task 7: UI DTO + ViewModels + tests (TDD)
Files:
-
Modify:
src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs -
Modify:
src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs -
Modify:
src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs -
Test:
tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs -
Step 1: Update the UI DTO
src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs (keep PrimeFiredEvent unchanged):
namespace ClaudeDo.Ui.Services;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
public sealed record PrimeFiredEvent(
Guid ScheduleId,
bool Success,
string Message,
DateTimeOffset FiredAt);
- Step 2: Rewrite the row VM
Replace the body of src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs:
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
{
private const int Mon = 1, Tue = 2, Wed = 4, Thu = 8, Fri = 16, Sat = 32, Sun = 64;
public Guid Id { get; }
public bool IsExisting { get; }
[ObservableProperty] private bool _enabled;
[ObservableProperty] private bool _monday;
[ObservableProperty] private bool _tuesday;
[ObservableProperty] private bool _wednesday;
[ObservableProperty] private bool _thursday;
[ObservableProperty] private bool _friday;
[ObservableProperty] private bool _saturday;
[ObservableProperty] private bool _sunday;
[ObservableProperty] private TimeSpan _timeOfDay;
[ObservableProperty] private DateTimeOffset? _lastRunAt;
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel));
public PrimeScheduleRowViewModel(PrimeScheduleDto dto, bool isExisting)
{
Id = dto.Id;
IsExisting = isExisting;
Enabled = dto.Enabled;
Monday = (dto.Days & Mon) != 0;
Tuesday = (dto.Days & Tue) != 0;
Wednesday = (dto.Days & Wed) != 0;
Thursday = (dto.Days & Thu) != 0;
Friday = (dto.Days & Fri) != 0;
Saturday = (dto.Days & Sat) != 0;
Sunday = (dto.Days & Sun) != 0;
TimeOfDay = dto.TimeOfDay;
LastRunAt = dto.LastRunAt;
}
public int DaysMask()
{
int m = 0;
if (Monday) m |= Mon;
if (Tuesday) m |= Tue;
if (Wednesday) m |= Wed;
if (Thursday) m |= Thu;
if (Friday) m |= Fri;
if (Saturday) m |= Sat;
if (Sunday) m |= Sun;
return m;
}
public PrimeScheduleDto ToDto() =>
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, null);
}
- Step 3: Update the tab VM
In src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs, replace Validate and AddSchedule:
public string? Validate()
{
foreach (var r in Rows)
{
if (r.DaysMask() == 0)
return $"Schedule {r.TimeOfDay:hh\\:mm}: select at least one day.";
if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1))
return "Time must be between 00:00 and 23:59.";
}
return null;
}
[RelayCommand]
private void AddSchedule()
{
var dto = new PrimeScheduleDto(
Id: Guid.NewGuid(),
Days: 31, // Mon–Fri
TimeOfDay: new TimeSpan(7, 0, 0),
Enabled: true,
LastRunAt: null,
PromptOverride: null);
Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false));
}
LoadAsync, SaveAsync, RemoveSchedule, ApplyFiredEvent are unchanged.
- Step 4: Rewrite the tab VM tests
Replace the body of tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs:
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class PrimeClaudeTabViewModelTests
{
private sealed class FakeApi : IPrimeScheduleApi
{
public List<PrimeScheduleDto> Stored { get; } = new();
public List<PrimeScheduleDto> Upserts { get; } = new();
public List<Guid> Deletes { get; } = new();
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(Stored.ToList());
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto)
{
Upserts.Add(dto);
return Task.FromResult<PrimeScheduleDto?>(dto);
}
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
}
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) =>
new(id, days, time, true, null, null);
[Fact]
public async Task Load_Populates_Rows()
{
var api = new FakeApi();
api.Stored.Add(Dto(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0)));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
Assert.Single(vm.Rows);
}
[Fact]
public void AddSchedule_Appends_Row_With_Defaults()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Single(vm.Rows);
Assert.True(vm.Rows[0].Enabled);
Assert.True(vm.Rows[0].Monday);
Assert.True(vm.Rows[0].Friday);
Assert.False(vm.Rows[0].Saturday);
Assert.Equal(new TimeSpan(7, 0, 0), vm.Rows[0].TimeOfDay);
}
[Fact]
public void Row_Decomposes_And_Recomposes_Days()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
Assert.Equal(31, row.DaysMask());
row.Saturday = true;
Assert.Equal(63, row.DaysMask());
}
[Fact]
public async Task Save_Diffs_New_And_Removed_Rows()
{
var api = new FakeApi();
var keptId = Guid.NewGuid();
var deletedId = Guid.NewGuid();
api.Stored.Add(Dto(keptId, 31, new TimeSpan(7, 0, 0)));
api.Stored.Add(Dto(deletedId, 31, new TimeSpan(8, 0, 0)));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId));
vm.AddScheduleCommand.Execute(null);
await vm.SaveAsync();
Assert.Contains(deletedId, api.Deletes);
Assert.Equal(2, api.Upserts.Count);
}
[Fact]
public void Validate_Reports_No_Days_Selected()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
row.Monday = row.Tuesday = row.Wednesday = row.Thursday = row.Friday = false;
Assert.NotNull(vm.Validate());
}
[Fact]
public void Validate_Passes_With_One_Day()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Null(vm.Validate());
}
}
- Step 5: Run UI tests
Run: dotnet test tests/ClaudeDo.Ui.Tests --filter FullyQualifiedName~PrimeClaudeTabViewModelTests
Expected: PASS.
- Step 6: Commit
git add src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs
git commit -m "feat(ui): drive prime schedule rows from weekday toggles"
Task 8: XAML — toggle-button row
Files:
-
Modify:
src/ClaudeDo.Ui/Design/IslandStyles.axaml -
Modify:
src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml -
Step 1: Add a
day-togglestyle class
Append to src/ClaudeDo.Ui/Design/IslandStyles.axaml (inside the root <Styles> element, alongside the other style selectors). Uses existing dynamic-resource tokens — no hardcoded colors:
<Style Selector="ToggleButton.day-toggle">
<Setter Property="MinWidth" Value="34"/>
<Setter Property="Padding" Value="6,4"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
<Style Selector="ToggleButton.day-toggle:checked /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
</Style>
If AccentBrush is not a defined token, use the brush the project uses for primary/selected affordances (check the primary button style in this file and reuse that brush). Final visual pass is the user's.
- Step 2: Replace the Prime row template
In src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml, replace the <Grid ...> inside the Prime DataTemplate (currently columns Auto,*,Auto,Auto,Auto,Auto with the ThemedDatePicker and Mon–Fri checkbox) with:
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<ToggleButton Classes="day-toggle" Content="Mo" IsChecked="{Binding Monday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Tu" IsChecked="{Binding Tuesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="We" IsChecked="{Binding Wednesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Th" IsChecked="{Binding Thursday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Fr" IsChecked="{Binding Friday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Sa" IsChecked="{Binding Saturday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Su" IsChecked="{Binding Sunday, Mode=TwoWay}"/>
</StackPanel>
<TextBox Grid.Column="2" Width="64"
Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
MinWidth="80"/>
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/>
</Grid>
- Step 3: Update the explainer text
Replace the intro TextBlock Text in the Prime tab (SettingsModalView.axaml):
Text="Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately."/>
- Step 4: Remove the now-unused range converter (only if unreferenced)
The DateOnlyToDateTime resource on line 23 was used only by the range picker. Grep the file: if DateOnlyToDateTime has no other reference, remove the <conv:DateOnlyToDateTimeConverter x:Key="DateOnlyToDateTime"/> line. Keep TimeSpanToHhmm (still used).
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 5: Manual UI check
Start the worker, then the app. Open Settings → Prime Claude. Verify: a row shows 7 toggle buttons with Mon–Fri lit by default; toggling Sat/Sun persists after Save+reopen; clearing all days shows the validation error on Save. (UI correctness can only be confirmed in the running app — state so explicitly if it cannot be run.)
- Step 6: Commit
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
git commit -m "feat(ui): replace prime date range with weekday toggle buttons"
Task 9: Docs
Files:
-
Modify:
src/ClaudeDo.Data/CLAUDE.md -
Modify:
CLAUDE.md -
Step 1: Update the Data CLAUDE.md
In src/ClaudeDo.Data/CLAUDE.md, the Models section has no PrimeSchedule line today; add one under Models, and confirm the prime_schedules table mention in the Schema section stays accurate:
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
- Step 2: Update the root CLAUDE.md if Prime is described
Grep CLAUDE.md for "Prime"; if there is a Prime description mentioning a date range, update it to "recurring weekday schedule". If there is no such line, make no change.
- Step 3: Full test sweep
Run: dotnet test tests/ClaudeDo.Worker.Tests && dotnet test tests/ClaudeDo.Ui.Tests
Expected: PASS.
- Step 4: Commit
git add src/ClaudeDo.Data/CLAUDE.md CLAUDE.md
git commit -m "docs: describe recurring-weekday Prime schedule"
Self-Review Notes
- Spec coverage: data model (T1), scheduling logic (T4), UI toggles (T7–T8), migration+backfill (T3), both DTOs (T4/T7), tests (T4–T7), out-of-scope items excluded. ✓
- Type consistency: entity
PrimeDays Days; both DTOsint Days; hub/scheduler cast(int)/(PrimeDays)at boundaries; calculator casts(PrimeDays)s.Days; row VM exposes 7 bools +DaysMask(). ✓ - Build ripple: a single type change breaks several projects at once, so some intermediate steps note expected build failures; the gating green builds are T3 Step 4 (Data), T6 Step 4 (Worker + tests), T8 Step 4 (App). ✓