From 13c3393e3aa47b8ee13c67f8077efeb11d89641d Mon Sep 17 00:00:00 2001 From: mika kuns Date: Tue, 2 Jun 2026 15:48:51 +0200 Subject: [PATCH] docs: implementation plan for recurring-weekday Prime Co-Authored-By: Claude Opus 4.7 --- .../2026-06-02-prime-recurring-weekdays.md | 983 ++++++++++++++++++ 1 file changed, 983 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-prime-recurring-weekdays.md diff --git a/docs/superpowers/plans/2026-06-02-prime-recurring-weekdays.md b/docs/superpowers/plans/2026-06-02-prime-recurring-weekdays.md new file mode 100644 index 0000000..3f777eb --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-prime-recurring-weekdays.md @@ -0,0 +1,983 @@ +# 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` — `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 2–3 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> 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/_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( + 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( + name: "start_date", table: "prime_schedules", + type: "TEXT", nullable: false, defaultValue: new DateOnly(2000, 1, 1)); + migrationBuilder.AddColumn( + name: "end_date", table: "prime_schedules", + type: "TEXT", nullable: false, defaultValue: new DateOnly(2099, 12, 31)); + migrationBuilder.AddColumn( + 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 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** + +```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> 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 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, // 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`: + +```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 Stored { get; } = new(); + public List Upserts { get; } = new(); + public List Deletes { get; } = new(); + public Task> ListAsync() => Task.FromResult(Stored.ToList()); + public Task UpsertAsync(PrimeScheduleDto dto) + { + Upserts.Add(dto); + return Task.FromResult(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 `` element, alongside the other style selectors). Uses existing dynamic-resource tokens — no hardcoded colors: + +```xml + + +``` + +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 `` inside the Prime `DataTemplate` (currently columns `Auto,*,Auto,Auto,Auto,Auto` with the `ThemedDatePicker` and Mon–Fri checkbox) with: + +```xml + + + + + + + + + + + + + +