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

984 lines
37 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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``ToDto` mapping.
- `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-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`:
```csharp
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:
```csharp
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):
```csharp
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**
```bash
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`:
```csharp
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`:
```csharp
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)
```bash
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:
```bash
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:
```csharp
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**
```csharp
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**
```bash
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`:
```csharp
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.
```csharp
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`:
```csharp
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**
```bash
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`:
```csharp
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:
```csharp
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**
```bash
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`**
```csharp
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`**
```csharp
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:
```csharp
// 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,
});
```
```csharp
// 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,
});
```
```csharp
// 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:
```csharp
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**
```bash
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):
```csharp
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`:
```csharp
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`:
```csharp
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;
}
```
```csharp
[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`:
```csharp
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**
```bash
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:
```xml
<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:
```xml
<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`):
```xml
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**
```bash
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:
```markdown
- **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**
```bash
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). ✓
```