Files
ClaudeDo/docs/superpowers/plans/2026-06-02-prime-recurring-weekdays.md
2026-06-02 15:48:51 +02:00

37 KiB
Raw Permalink Blame History

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 + MonFri 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.csnew, [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.csint Days.
  • src/ClaudeDo.Worker/Prime/NextDueCalculator.cs — weekday eligibility.
  • src/ClaudeDo.Worker/Prime/PrimeScheduler.csToDto mapping.
  • src/ClaudeDo.Worker/Hub/WorkerHub.cs — list/upsert mapping.
  • src/ClaudeDo.Ui/Services/PrimeScheduleDto.csint 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.axamlday-toggle style class.
  • src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml — row template.
  • Tests: NextDueCalculatorTests, PrimeSchedulerTests, PrimeScheduleRepositoryTests, PrimeClaudeTabViewModelTests.
  • Docs: src/ClaudeDo.Data/CLAUDE.md, root CLAUDE.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 23 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 ListAsync ordering

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 UpsertAsync field 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 Up body 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 Down body
        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 56, 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 ToDto mapping

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, // MonFri
            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-toggle style 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 MonFri 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 MonFri 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 (T7T8), migration+backfill (T3), both DTOs (T4/T7), tests (T4T7), out-of-scope items excluded. ✓
  • Type consistency: entity PrimeDays Days; both DTOs int 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). ✓