From 2ff0971dce14343a0a2da816928c42de71c092d7 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 28 Apr 2026 08:46:43 +0200 Subject: [PATCH] docs: add design + plan for tabbed settings + Prime Claude Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-04-28-tabbed-settings-prime-claude.md | 2424 +++++++++++++++++ ...-28-tabbed-settings-prime-claude-design.md | 272 ++ 2 files changed, 2696 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-28-tabbed-settings-prime-claude.md create mode 100644 docs/superpowers/specs/2026-04-28-tabbed-settings-prime-claude-design.md diff --git a/docs/superpowers/plans/2026-04-28-tabbed-settings-prime-claude.md b/docs/superpowers/plans/2026-04-28-tabbed-settings-prime-claude.md new file mode 100644 index 0000000..614c627 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-tabbed-settings-prime-claude.md @@ -0,0 +1,2424 @@ +# Tabbed Settings + Prime Claude — 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:** Restructure the Settings modal into a tabbed layout, move About to the Help menu, and add a "Prime Claude" tab that schedules a daily one-shot `claude -p "ping"` call to start the Claude usage window early. + +**Architecture:** New `PrimeScheduleEntity` + repository in `ClaudeDo.Data`. Event-driven `PrimeScheduler : BackgroundService` in `ClaudeDo.Worker` that waits on `Task.Delay(timeUntilNextDue, ct)` and is signaled by hub mutations. Hub adds list/upsert/delete + a `PrimeFired` broadcast event. `SettingsModalViewModel` is split into per-tab VMs. `MainWindow` Help menu gains an "About…" entry that opens a new modal. + +**Tech Stack:** .NET 8, EF Core (Sqlite) + migrations, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, SignalR, xUnit. + +**Spec:** `docs/superpowers/specs/2026-04-28-tabbed-settings-prime-claude-design.md` + +**Build / test reminder (project-specific):** `dotnet build ClaudeDo.slnx` fails on .NET 8 — always build individual csproj files. Run all tests via `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` and `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`. + +--- + +## Task 1: Add `PrimeScheduleEntity` + EF configuration + +**Files:** +- Create: `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs` +- Create: `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs` +- Modify: `src/ClaudeDo.Data/ClaudeDoDbContext.cs` (add `DbSet`) + +- [ ] **Step 1: Create the entity** + +```csharp +// src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs +namespace ClaudeDo.Data.Models; + +public sealed class PrimeScheduleEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } + public TimeSpan TimeOfDay { get; set; } + public bool WorkdaysOnly { get; set; } = true; + public bool Enabled { get; set; } = true; + public DateTimeOffset? LastRunAt { get; set; } + public string? PromptOverride { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} +``` + +- [ ] **Step 2: Create the EF configuration** + +```csharp +// src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("prime_schedules"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever(); + + builder.Property(s => s.StartDate).HasColumnName("start_date").IsRequired(); + builder.Property(s => s.EndDate).HasColumnName("end_date").IsRequired(); + builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired(); + builder.Property(s => s.WorkdaysOnly).HasColumnName("workdays_only").IsRequired().HasDefaultValue(true); + builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true); + builder.Property(s => s.LastRunAt).HasColumnName("last_run_at"); + builder.Property(s => s.PromptOverride).HasColumnName("prompt_override"); + builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} +``` + +- [ ] **Step 3: Register the DbSet** + +In `src/ClaudeDo.Data/ClaudeDoDbContext.cs`, add next to the other `DbSet` properties (after `AppSettings`): + +```csharp +public DbSet PrimeSchedules => Set(); +``` + +- [ ] **Step 4: Build to verify the model compiles** + +Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj` +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs src/ClaudeDo.Data/ClaudeDoDbContext.cs +git commit -m "feat(data): add PrimeScheduleEntity + configuration" +``` + +--- + +## Task 2: Add EF migration `AddPrimeSchedules` + +**Files:** +- Create: `src/ClaudeDo.Data/Migrations/_AddPrimeSchedules.cs` (auto-generated) +- Create: `src/ClaudeDo.Data/Migrations/_AddPrimeSchedules.Designer.cs` (auto-generated) +- Modify: `src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs` (auto-updated) + +- [ ] **Step 1: Generate the migration** + +Run from repo root: + +```bash +dotnet ef migrations add AddPrimeSchedules \ + --project src/ClaudeDo.Data/ClaudeDo.Data.csproj \ + --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +``` + +Expected: three files added/modified under `src/ClaudeDo.Data/Migrations/`. The `Up()` method should `CreateTable("prime_schedules", ...)` with the columns from Task 1. + +- [ ] **Step 2: Eyeball the generated `Up()` to verify columns** + +Open the generated `_AddPrimeSchedules.cs` and confirm columns: `id` (TEXT PK), `start_date`, `end_date`, `time_of_day`, `workdays_only`, `enabled`, `last_run_at` (nullable), `prompt_override` (nullable), `created_at`. + +- [ ] **Step 3: Build worker to confirm migration applies** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Data/Migrations/ +git commit -m "feat(data): add AddPrimeSchedules migration" +``` + +--- + +## Task 3: Add `PrimeScheduleRepository` (TDD) + +**Files:** +- Create: `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs` + +- [ ] **Step 1: Write failing repository test** + +```csharp +// tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.Repositories; + +public class PrimeScheduleRepositoryTests : IDisposable +{ + private readonly DbFixture _db = new(); + public void Dispose() => _db.Dispose(); + + [Fact] + public async Task Upsert_Then_List_RoundTrips() + { + var id = Guid.NewGuid(); + using (var ctx = _db.CreateContext()) + { + await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity + { + Id = id, + StartDate = new DateOnly(2026, 5, 1), + EndDate = new DateOnly(2026, 6, 30), + TimeOfDay = new TimeSpan(7, 0, 0), + WorkdaysOnly = true, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + }); + } + + using var read = _db.CreateContext(); + var rows = await new PrimeScheduleRepository(read).ListAsync(); + Assert.Single(rows); + Assert.Equal(id, rows[0].Id); + Assert.Equal(new TimeSpan(7, 0, 0), rows[0].TimeOfDay); + } + + [Fact] + public async Task UpdateLastRunAt_Persists() + { + var id = Guid.NewGuid(); + var when = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2)); + using (var ctx = _db.CreateContext()) + { + await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity + { + Id = id, + StartDate = new DateOnly(2026, 5, 1), + EndDate = new DateOnly(2026, 5, 31), + TimeOfDay = new TimeSpan(7, 0, 0), + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + }); + } + using (var ctx = _db.CreateContext()) + await new PrimeScheduleRepository(ctx).UpdateLastRunAsync(id, when); + + using var read = _db.CreateContext(); + var row = await new PrimeScheduleRepository(read).GetAsync(id); + Assert.NotNull(row); + Assert.Equal(when, row!.LastRunAt); + } + + [Fact] + public async Task Delete_Removes_Row() + { + var id = Guid.NewGuid(); + using (var ctx = _db.CreateContext()) + await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity + { + Id = id, + StartDate = new DateOnly(2026, 5, 1), + EndDate = new DateOnly(2026, 5, 1), + TimeOfDay = TimeSpan.Zero, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + }); + using (var ctx = _db.CreateContext()) + await new PrimeScheduleRepository(ctx).DeleteAsync(id); + + using var read = _db.CreateContext(); + Assert.Empty(await new PrimeScheduleRepository(read).ListAsync()); + } +} +``` + +- [ ] **Step 2: Run test, confirm failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeScheduleRepositoryTests"` +Expected: compile error — `PrimeScheduleRepository` does not exist. + +- [ ] **Step 3: Implement the repository** + +```csharp +// src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class PrimeScheduleRepository +{ + private readonly ClaudeDoDbContext _context; + + public PrimeScheduleRepository(ClaudeDoDbContext context) => _context = context; + + public async Task> ListAsync(CancellationToken ct = default) => + await _context.PrimeSchedules.AsNoTracking() + .OrderBy(s => s.StartDate).ThenBy(s => s.TimeOfDay) + .ToListAsync(ct); + + public async Task GetAsync(Guid id, CancellationToken ct = default) => + await _context.PrimeSchedules.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct); + + public async Task UpsertAsync(PrimeScheduleEntity entity, CancellationToken ct = default) + { + var existing = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == entity.Id, ct); + if (existing is null) + { + _context.PrimeSchedules.Add(entity); + } + else + { + existing.StartDate = entity.StartDate; + existing.EndDate = entity.EndDate; + existing.TimeOfDay = entity.TimeOfDay; + existing.WorkdaysOnly = entity.WorkdaysOnly; + existing.Enabled = entity.Enabled; + existing.PromptOverride = entity.PromptOverride; + // CreatedAt + LastRunAt are not overwritten by upsert + } + await _context.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct); + if (row is null) return; + _context.PrimeSchedules.Remove(row); + await _context.SaveChangesAsync(ct); + } + + public async Task UpdateLastRunAsync(Guid id, DateTimeOffset when, CancellationToken ct = default) + { + var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct); + if (row is null) return; + row.LastRunAt = when; + await _context.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 4: Run tests, confirm pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeScheduleRepositoryTests"` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs +git commit -m "feat(data): add PrimeScheduleRepository" +``` + +--- + +## Task 4: Add `PrimeScheduleDto` + +**Files:** +- Create: `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs` + +- [ ] **Step 1: Create the folder and DTO** + +```csharp +// src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs +namespace ClaudeDo.Worker.Prime; + +public sealed record PrimeScheduleDto( + Guid Id, + DateOnly StartDate, + DateOnly EndDate, + TimeSpan TimeOfDay, + bool WorkdaysOnly, + bool Enabled, + DateTimeOffset? LastRunAt, + string? PromptOverride); +``` + +- [ ] **Step 2: Build to confirm** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs +git commit -m "feat(worker): add PrimeScheduleDto" +``` + +--- + +## Task 5: Add scheduler abstractions (`IPrimeClock`, `IPrimeRunner`, `IPrimeScheduleSignal`) + +**Files:** +- Create: `src/ClaudeDo.Worker/Prime/IPrimeClock.cs` +- Create: `src/ClaudeDo.Worker/Prime/IPrimeRunner.cs` +- Create: `src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs` +- Create: `src/ClaudeDo.Worker/Prime/PrimeClock.cs` +- Create: `src/ClaudeDo.Worker/Prime/PrimeScheduleSignal.cs` +- Create: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs` +- Create: `src/ClaudeDo.Worker/Prime/PrimeSchedulerOptions.cs` + +- [ ] **Step 1: Clock** + +```csharp +// src/ClaudeDo.Worker/Prime/IPrimeClock.cs +namespace ClaudeDo.Worker.Prime; +public interface IPrimeClock { DateTimeOffset Now { get; } } +``` + +```csharp +// src/ClaudeDo.Worker/Prime/PrimeClock.cs +namespace ClaudeDo.Worker.Prime; +public sealed class PrimeClock : IPrimeClock +{ + public DateTimeOffset Now => DateTimeOffset.Now; +} +``` + +- [ ] **Step 2: Signal (waker for the scheduler)** + +```csharp +// src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs +namespace ClaudeDo.Worker.Prime; +public interface IPrimeScheduleSignal +{ + void Signal(); + CancellationToken CurrentToken { get; } +} +``` + +```csharp +// src/ClaudeDo.Worker/Prime/PrimeScheduleSignal.cs +namespace ClaudeDo.Worker.Prime; + +public sealed class PrimeScheduleSignal : IPrimeScheduleSignal, IDisposable +{ + private CancellationTokenSource _cts = new(); + private readonly object _lock = new(); + + public CancellationToken CurrentToken + { + get { lock (_lock) return _cts.Token; } + } + + public void Signal() + { + CancellationTokenSource old; + lock (_lock) + { + old = _cts; + _cts = new CancellationTokenSource(); + } + try { old.Cancel(); } catch { /* already cancelled */ } + old.Dispose(); + } + + public void Dispose() + { + lock (_lock) _cts.Dispose(); + } +} +``` + +- [ ] **Step 3: Runner contract** + +```csharp +// src/ClaudeDo.Worker/Prime/IPrimeRunner.cs +namespace ClaudeDo.Worker.Prime; + +public interface IPrimeRunner +{ + Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct); +} + +public sealed record PrimeRunOutcome(bool Success, string Message); +``` + +- [ ] **Step 4: Runner implementation** + +```csharp +// src/ClaudeDo.Worker/Prime/PrimeRunner.cs +using ClaudeDo.Data; +using ClaudeDo.Worker.Runner; + +namespace ClaudeDo.Worker.Prime; + +public sealed class PrimeRunner : IPrimeRunner +{ + private static readonly TimeSpan FireTimeout = TimeSpan.FromSeconds(60); + private readonly IClaudeProcess _claude; + private readonly ILogger _logger; + + public PrimeRunner(IClaudeProcess claude, ILogger logger) + { + _claude = claude; + _logger = logger; + } + + public async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) + { + var cwd = Paths.AppDataRoot(); + Directory.CreateDirectory(cwd); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(FireTimeout); + + try + { + var prompt = schedule.PromptOverride ?? "ping"; + var result = await _claude.RunAsync( + arguments: "-p --max-turns 1", + prompt: prompt, + workingDirectory: cwd, + onStdoutLine: _ => Task.CompletedTask, + ct: timeoutCts.Token); + + if (result.ExitCode == 0) + return new PrimeRunOutcome(true, "Primed Claude"); + return new PrimeRunOutcome(false, $"exit {result.ExitCode}"); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalSeconds:0}s"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Prime fire failed"); + return new PrimeRunOutcome(false, ex.Message); + } + } +} +``` + +(Note: `RunResult.ExitCode` is the exit code surfaced by `ClaudeProcess`. If the existing `RunResult` shape differs, expose the int via the same property name used internally — this is the only field consumed here.) + +- [ ] **Step 5: Options record** + +```csharp +// src/ClaudeDo.Worker/Prime/PrimeSchedulerOptions.cs +namespace ClaudeDo.Worker.Prime; + +public sealed record PrimeSchedulerOptions(TimeSpan CatchUpWindow) +{ + public static PrimeSchedulerOptions Default { get; } = + new(TimeSpan.FromMinutes(30)); +} +``` + +- [ ] **Step 6: Build to confirm** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: build succeeds. + +If `RunResult` doesn't expose `ExitCode` directly, open `src/ClaudeDo.Worker/Runner/RunResult.cs` and use the equivalent field (e.g. `Status`, `IsSuccess`). The contract is: success when the process exited cleanly. Adjust the `if` branch accordingly and re-build. + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Worker/Prime/ +git commit -m "feat(worker): add Prime scheduler abstractions + runner" +``` + +--- + +## Task 6: Add `NextDueCalculator` (pure function, TDD) + +**Files:** +- Create: `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs` + +This pure function is the heart of the scheduler. Isolating it makes the BackgroundService thin and testable. + +- [ ] **Step 1: Write failing tests** + +```csharp +// tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs +using ClaudeDo.Worker.Prime; + +namespace ClaudeDo.Worker.Tests.Prime; + +public class NextDueCalculatorTests +{ + private static PrimeScheduleDto Schedule( + DateOnly start, DateOnly end, TimeSpan time, + bool workdaysOnly = true, bool enabled = true, DateTimeOffset? lastRun = null) => + new(Guid.NewGuid(), start, end, time, workdaysOnly, enabled, lastRun, null); + + [Fact] + public void Disabled_Schedule_Returns_Null() + { + var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), enabled: false); + Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); + } + + [Fact] + public void Future_Same_Day_Returns_Today_At_Target() + { + // Tuesday 06:00 local, target 07:00 same day + var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + 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() + { + // Tuesday 07:15, target was 07:00, catch-up 30min + var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.True(r!.FireImmediately); + } + + [Fact] + public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day() + { + // Tuesday 09:00, target was 07:00, catch-up 30min → next is Wednesday 07:00 + var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); + } + + [Fact] + public void WorkdaysOnly_Skips_Weekend() + { + // Friday 08:00, target was 07:00 (past catch-up) → next eligible is Monday + var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), workdaysOnly: true); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + 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 Already_Fired_Today_Skips_To_Tomorrow() + { + // Today 06:00, lastRun was today 07:01 → next is tomorrow + var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); + var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), lastRun: lastRun); + var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); + } + + [Fact] + public void Past_EndDate_Returns_Null() + { + var now = new DateTimeOffset(2026, 6, 1, 6, 0, 0, TimeSpan.FromHours(2)); + var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); + } + + [Fact] + public void Multiple_Schedules_Returns_Earliest() + { + var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); + var early = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); + var late = Schedule(new(2026,5,1), new(2026,5,31), new(9,0,0)); + var r = NextDueCalculator.Compute(new[]{late, early}, now, TimeSpan.FromMinutes(30)); + Assert.NotNull(r); + Assert.Equal(early.Id, r!.Schedule.Id); + } +} +``` + +- [ ] **Step 2: Run tests, confirm failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~NextDueCalculatorTests"` +Expected: compile error — `NextDueCalculator` does not exist. + +- [ ] **Step 3: Implement `NextDueCalculator`** + +```csharp +// src/ClaudeDo.Worker/Prime/NextDueCalculator.cs +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 (s.EndDate < DateOnly.FromDateTime(now.LocalDateTime)) return null; + + var todayLocal = DateOnly.FromDateTime(now.LocalDateTime); + var alreadyFiredToday = s.LastRunAt is { } last && + DateOnly.FromDateTime(last.LocalDateTime) == todayLocal; + + // Try today first + if (!alreadyFiredToday) + { + var startOrToday = s.StartDate > todayLocal ? s.StartDate : todayLocal; + if (startOrToday == todayLocal && IsEligibleDay(s, todayLocal)) + { + var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset); + if (todayTarget >= now) + return new NextDue(s, todayTarget, false); + if (now <= todayTarget + catchUp) + return new NextDue(s, now, true); + // else fall through to tomorrow + } + } + + // Find the next eligible day strictly after today + var d = todayLocal.AddDays(1); + if (s.StartDate > d) d = s.StartDate; + for (int i = 0; i < 8; i++) // at most a week ahead to find a workday + { + if (d > s.EndDate) return null; + if (IsEligibleDay(s, d)) + return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false); + d = d.AddDays(1); + } + return null; + } + + private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) + { + if (d < s.StartDate || d > s.EndDate) return false; + if (!s.WorkdaysOnly) return true; + var dow = d.ToDateTime(TimeOnly.MinValue).DayOfWeek; + return dow != DayOfWeek.Saturday && dow != DayOfWeek.Sunday; + } + + 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 4: Run tests, confirm pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~NextDueCalculatorTests"` +Expected: 8 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Prime/NextDueCalculator.cs tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs +git commit -m "feat(worker): add NextDueCalculator with workday + catch-up logic" +``` + +--- + +## Task 7: Add `PrimeScheduler : BackgroundService` + integration tests + +**Files:** +- Create: `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs` + +This BackgroundService is thin: it loops, computes next due, awaits, and fires. Logic lives in `NextDueCalculator`; concrete IO lives in `IPrimeRunner`. + +- [ ] **Step 1: Write failing scheduler tests** + +```csharp +// tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Prime; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Prime; + +public class PrimeSchedulerTests : IDisposable +{ + private readonly DbFixture _db = new(); + public void Dispose() => _db.Dispose(); + + private sealed class FakeClock : IPrimeClock + { + public DateTimeOffset Now { get; set; } + } + + private sealed class FakeRunner : IPrimeRunner + { + public List FiredIds { get; } = new(); + public Task FireAsync(PrimeScheduleDto s, CancellationToken ct) + { + FiredIds.Add(s.Id); + return Task.FromResult(new PrimeRunOutcome(true, "ok")); + } + } + + private sealed class FakeBroadcaster : IPrimeBroadcaster + { + public List<(Guid id, bool ok, string msg)> Calls { get; } = new(); + public Task PrimeFiredAsync(Guid id, bool ok, string msg, DateTimeOffset firedAt) + { + Calls.Add((id, ok, msg)); + return Task.CompletedTask; + } + } + + [Fact] + public async Task Fires_Schedule_When_Within_CatchUp_On_Startup() + { + var id = Guid.NewGuid(); + using (var ctx = _db.CreateContext()) + await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity + { + Id = id, + StartDate = new DateOnly(2026, 5, 5), + EndDate = new DateOnly(2026, 5, 5), + TimeOfDay = new TimeSpan(7, 0, 0), + WorkdaysOnly = false, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + }); + + var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 7, 10, 0, TimeSpan.FromHours(2)) }; + var runner = new FakeRunner(); + var broadcaster = new FakeBroadcaster(); + var signal = new PrimeScheduleSignal(); + var scheduler = new PrimeScheduler( + _db.CreateFactory(), runner, clock, signal, broadcaster, + PrimeSchedulerOptions.Default, NullLogger.Instance); + + using var cts = new CancellationTokenSource(); + var run = scheduler.StartAsync(cts.Token); + + // Allow scheduler to fire + await WaitFor(() => runner.FiredIds.Count >= 1, TimeSpan.FromSeconds(3)); + + cts.Cancel(); + await scheduler.StopAsync(CancellationToken.None); + + Assert.Single(runner.FiredIds); + Assert.Equal(id, runner.FiredIds[0]); + Assert.Single(broadcaster.Calls); + Assert.True(broadcaster.Calls[0].ok); + + using var read = _db.CreateContext(); + var row = await new PrimeScheduleRepository(read).GetAsync(id); + Assert.NotNull(row!.LastRunAt); + } + + [Fact] + public async Task Does_Not_Fire_Past_CatchUp_Window() + { + var id = Guid.NewGuid(); + using (var ctx = _db.CreateContext()) + await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity + { + Id = id, + StartDate = new DateOnly(2026, 5, 5), + EndDate = new DateOnly(2026, 5, 5), + TimeOfDay = new TimeSpan(7, 0, 0), + WorkdaysOnly = false, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + }); + + // 09:00 — 2h after target, well past 30min catch-up + var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)) }; + var runner = new FakeRunner(); + var signal = new PrimeScheduleSignal(); + var scheduler = new PrimeScheduler( + _db.CreateFactory(), runner, clock, signal, new FakeBroadcaster(), + PrimeSchedulerOptions.Default, NullLogger.Instance); + + using var cts = new CancellationTokenSource(); + await scheduler.StartAsync(cts.Token); + await Task.Delay(200); + cts.Cancel(); + await scheduler.StopAsync(CancellationToken.None); + + Assert.Empty(runner.FiredIds); + } + + [Fact] + public async Task Signal_Recomputes_Mid_Wait() + { + // Empty DB, scheduler waits on signal. Insert a due schedule and signal — fires. + var clock = new FakeClock { Now = new DateTimeOffset(2026, 5, 5, 7, 10, 0, TimeSpan.FromHours(2)) }; + var runner = new FakeRunner(); + var signal = new PrimeScheduleSignal(); + var scheduler = new PrimeScheduler( + _db.CreateFactory(), runner, clock, signal, new FakeBroadcaster(), + PrimeSchedulerOptions.Default, NullLogger.Instance); + + using var cts = new CancellationTokenSource(); + await scheduler.StartAsync(cts.Token); + + var id = Guid.NewGuid(); + using (var ctx = _db.CreateContext()) + await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity + { + Id = id, + StartDate = new DateOnly(2026, 5, 5), + EndDate = new DateOnly(2026, 5, 5), + TimeOfDay = new TimeSpan(7, 0, 0), + WorkdaysOnly = false, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + }); + signal.Signal(); + + await WaitFor(() => runner.FiredIds.Count >= 1, TimeSpan.FromSeconds(3)); + cts.Cancel(); + await scheduler.StopAsync(CancellationToken.None); + + Assert.Single(runner.FiredIds); + } + + private static async Task WaitFor(Func cond, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!cond() && DateTime.UtcNow < deadline) + await Task.Delay(20); + } +} +``` + +- [ ] **Step 2: Run tests, confirm failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeSchedulerTests"` +Expected: compile error — `PrimeScheduler` and `IPrimeBroadcaster` do not exist. + +- [ ] **Step 3: Add `IPrimeBroadcaster`** + +```csharp +// src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs +namespace ClaudeDo.Worker.Prime; + +public interface IPrimeBroadcaster +{ + Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt); +} +``` + +- [ ] **Step 4: Implement `PrimeScheduler`** + +```csharp +// src/ClaudeDo.Worker/Prime/PrimeScheduler.cs +using ClaudeDo.Data; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; + +namespace ClaudeDo.Worker.Prime; + +public sealed class PrimeScheduler : BackgroundService +{ + private readonly IDbContextFactory _dbFactory; + private readonly IPrimeRunner _runner; + private readonly IPrimeClock _clock; + private readonly IPrimeScheduleSignal _signal; + private readonly IPrimeBroadcaster _broadcaster; + private readonly PrimeSchedulerOptions _options; + private readonly ILogger _logger; + + public PrimeScheduler( + IDbContextFactory dbFactory, + IPrimeRunner runner, + IPrimeClock clock, + IPrimeScheduleSignal signal, + IPrimeBroadcaster broadcaster, + PrimeSchedulerOptions options, + ILogger logger) + { + _dbFactory = dbFactory; + _runner = runner; + _clock = clock; + _signal = signal; + _broadcaster = broadcaster; + _options = options; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await TickAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PrimeScheduler tick failed; backing off"); + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } + } + + private async Task TickAsync(CancellationToken stoppingToken) + { + var schedules = await LoadAsync(stoppingToken); + var now = _clock.Now; + var due = NextDueCalculator.Compute(schedules, now, _options.CatchUpWindow); + + var signalToken = _signal.CurrentToken; + using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, signalToken); + + if (due is null) + { + try { await Task.Delay(TimeSpan.FromHours(1), linked.Token); } + catch (OperationCanceledException) { /* signal or shutdown */ } + return; + } + + var delay = due.FireImmediately ? TimeSpan.Zero : due.At - now; + if (delay > TimeSpan.Zero) + { + try { await Task.Delay(delay, linked.Token); } + catch (OperationCanceledException) + { + if (signalToken.IsCancellationRequested) return; // recompute + throw; + } + } + + await FireAsync(due.Schedule, stoppingToken); + } + + private async Task> LoadAsync(CancellationToken ct) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var rows = await new PrimeScheduleRepository(ctx).ListAsync(ct); + return rows.Select(ToDto).ToList(); + } + + private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) => + new(e.Id, e.StartDate, e.EndDate, e.TimeOfDay, e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride); + + private async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) + { + var firedAt = _clock.Now; + var outcome = await _runner.FireAsync(schedule, ct); + + await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) + await new PrimeScheduleRepository(ctx).UpdateLastRunAsync(schedule.Id, firedAt, ct); + + await _broadcaster.PrimeFiredAsync(schedule.Id, outcome.Success, outcome.Message, firedAt); + + if (outcome.Success) + _logger.LogInformation("Prime fired {Id} at {When}", schedule.Id, firedAt); + else + _logger.LogWarning("Prime failed {Id}: {Msg}", schedule.Id, outcome.Message); + } +} +``` + +- [ ] **Step 5: Run tests, confirm pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PrimeSchedulerTests"` +Expected: 3 passed. (If `Signal_Recomputes_Mid_Wait` is flaky on slow CI, increase the `WaitFor` timeout.) + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs src/ClaudeDo.Worker/Prime/PrimeScheduler.cs tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs +git commit -m "feat(worker): add PrimeScheduler hosted service" +``` + +--- + +## Task 8: Wire `HubBroadcaster` to publish `PrimeFired` + +**Files:** +- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` + +- [ ] **Step 1: Add the broadcast method + implement `IPrimeBroadcaster`** + +In `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`, add this method alongside the existing ones, and make the class implement `IPrimeBroadcaster`: + +```csharp +// add to using's: +using ClaudeDo.Worker.Prime; +``` + +Change the class declaration: + +```csharp +public sealed class HubBroadcaster : IPrimeBroadcaster +``` + +Append: + +```csharp +public Task PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) => + _hub.Clients.All.SendAsync("PrimeFired", scheduleId, success, message, firedAt); + +Task IPrimeBroadcaster.PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) => + PrimeFired(scheduleId, success, message, firedAt); +``` + +- [ ] **Step 2: Build to confirm** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +git commit -m "feat(worker): broadcast PrimeFired SignalR event" +``` + +--- + +## Task 9: Add hub methods (`ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`) + +**Files:** +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` + +- [ ] **Step 1: Add `using` and inject `IPrimeScheduleSignal`** + +Add to imports: + +```csharp +using ClaudeDo.Worker.Prime; +``` + +Add a private field and constructor parameter for `IPrimeScheduleSignal _primeSignal`. Pattern matches the existing constructor — append the parameter at the end and assign it. + +- [ ] **Step 2: Add the three hub methods** + +Append below `UpdateAppSettings`: + +```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, e.StartDate, e.EndDate, e.TimeOfDay, + e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList(); +} + +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, + StartDate = dto.StartDate, + EndDate = dto.EndDate, + TimeOfDay = dto.TimeOfDay, + WorkdaysOnly = dto.WorkdaysOnly, + 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, entity.StartDate, entity.EndDate, entity.TimeOfDay, + entity.WorkdaysOnly, entity.Enabled, entity.LastRunAt, entity.PromptOverride); +} + +public async Task DeletePrimeSchedule(Guid id) +{ + using var ctx = _dbFactory.CreateDbContext(); + await new PrimeScheduleRepository(ctx).DeleteAsync(id); + _primeSignal.Signal(); +} +``` + +- [ ] **Step 3: Add a using for the repository** + +```csharp +using ClaudeDo.Data.Repositories; +``` + +(Already imported earlier in the file — verify.) + +- [ ] **Step 4: Build worker** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Hub/WorkerHub.cs +git commit -m "feat(worker): add Prime schedule hub methods" +``` + +--- + +## Task 10: Register Prime services in `Program.cs` + +**Files:** +- Modify: `src/ClaudeDo.Worker/Program.cs` + +- [ ] **Step 1: Add the registrations** + +Just before `builder.Services.AddHostedService(sp => sp.GetRequiredService());` (around line 79), add: + +```csharp +// Prime Claude +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(PrimeSchedulerOptions.Default); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(); +``` + +Add `using ClaudeDo.Worker.Prime;` to the imports if not already present. + +- [ ] **Step 2: Build to confirm wiring** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: build succeeds, no DI resolution warnings. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Worker/Program.cs +git commit -m "feat(worker): register Prime services in DI" +``` + +--- + +## Task 11: Mirror `PrimeScheduleDto` + client methods in UI `WorkerClient` + +**Files:** +- Create: `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs` +- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs` + +The UI cannot reference `ClaudeDo.Worker`. Mirror the DTO shape used over the wire. + +- [ ] **Step 1: Create the UI-side DTO** + +```csharp +// src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs +namespace ClaudeDo.Ui.Services; + +public sealed record PrimeScheduleDto( + Guid Id, + DateOnly StartDate, + DateOnly EndDate, + TimeSpan TimeOfDay, + bool WorkdaysOnly, + bool Enabled, + DateTimeOffset? LastRunAt, + string? PromptOverride); + +public sealed record PrimeFiredEvent( + Guid ScheduleId, + bool Success, + string Message, + DateTimeOffset FiredAt); +``` + +- [ ] **Step 2: Add invoke methods + event handler in `WorkerClient`** + +In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, declare a public event near the existing event declarations: + +```csharp +public event Action? PrimeFired; +``` + +In the constructor, after the existing `_hub.On<...>` registrations, add: + +```csharp +_hub.On("PrimeFired", (id, ok, msg, when) => +{ + Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when))); +}); +``` + +Append public methods alongside the existing ones (e.g. after `UpdateAppSettingsAsync`): + +```csharp +public async Task> GetPrimeSchedulesAsync() +{ + try { return await _hub.InvokeAsync>("ListPrimeSchedules"); } + catch { return new List(); } +} + +public async Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto) +{ + try { return await _hub.InvokeAsync("UpsertPrimeSchedule", dto); } + catch { return null; } +} + +public async Task DeletePrimeScheduleAsync(Guid id) +{ + try { await _hub.InvokeAsync("DeletePrimeSchedule", id); } + catch { /* offline */ } +} +``` + +- [ ] **Step 3: Build UI** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs src/ClaudeDo.Ui/Services/WorkerClient.cs +git commit -m "feat(ui): add Prime schedule client + PrimeFired event" +``` + +--- + +## Task 12: Extract per-tab settings VMs (preparatory refactor) + +**Files:** +- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs` +- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs` +- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs` +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` + +This task moves logic only — no behavior changes. The View still binds to the same property paths via `Binding General.DefaultModel` once the View is updated in Task 14. + +- [ ] **Step 1: Create `GeneralSettingsTabViewModel`** + +```csharp +// src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class GeneralSettingsTabViewModel : ViewModelBase +{ + [ObservableProperty] private string _defaultClaudeInstructions = ""; + [ObservableProperty] private string _defaultModel = "sonnet"; + [ObservableProperty] private int _defaultMaxTurns = 100; + [ObservableProperty] private string _defaultPermissionMode = "auto"; + + public IReadOnlyList Models { get; } = new[] { "opus", "sonnet", "haiku" }; + public IReadOnlyList PermissionModes { get; } = new[] + { "auto", "bypassPermissions", "acceptEdits", "plan", "default" }; + + public string? Validate() + { + if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200) + return "Max turns must be between 1 and 200."; + return null; + } +} +``` + +- [ ] **Step 2: Create `WorktreesSettingsTabViewModel`** + +```csharp +// src/ClaudeDo.Ui/ViewModels/Modals/Settings/WorktreesSettingsTabViewModel.cs +using System.IO; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase +{ + private readonly WorkerClient _worker; + + [ObservableProperty] private string _worktreeStrategy = "sibling"; + [ObservableProperty] private string? _centralWorktreeRoot; + [ObservableProperty] private bool _worktreeAutoCleanupEnabled; + [ObservableProperty] private int _worktreeAutoCleanupDays = 7; + + [ObservableProperty] private bool _showResetConfirm; + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _isBusy; + + public IReadOnlyList WorktreeStrategies { get; } = new[] { "sibling", "central" }; + + public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker; + + public string? Validate() + { + if (WorktreeAutoCleanupEnabled && (WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365)) + return "Cleanup days must be between 1 and 365."; + if (WorktreeStrategy == "central") + { + if (string.IsNullOrWhiteSpace(CentralWorktreeRoot)) + return "Central worktree root is required for Central strategy."; + if (!Directory.Exists(CentralWorktreeRoot)) + return $"Directory not found: {CentralWorktreeRoot}"; + } + return null; + } + + [RelayCommand] + private async Task CleanupWorktrees() + { + IsBusy = true; StatusMessage = ""; + try + { + var r = await _worker.CleanupFinishedWorktreesAsync(); + StatusMessage = r is null ? "Worker offline." : $"Removed {r.Removed} worktree(s)."; + } + finally { IsBusy = false; } + } + + [RelayCommand] private void RequestResetConfirm() => ShowResetConfirm = true; + [RelayCommand] private void CancelResetConfirm() => ShowResetConfirm = false; + + [RelayCommand] + private async Task ConfirmResetAll() + { + ShowResetConfirm = false; IsBusy = true; StatusMessage = ""; + try + { + var r = await _worker.ResetAllWorktreesAsync(); + if (r is null) StatusMessage = "Worker offline."; + else if (r.Blocked) StatusMessage = $"Cannot force-remove: {r.RunningTasks} task(s) still running. Cancel them first."; + else StatusMessage = $"Removed {r.Removed} worktree(s) from {r.TasksAffected} task(s)."; + } + finally { IsBusy = false; } + } +} +``` + +- [ ] **Step 3: Create `FilesSettingsTabViewModel`** + +```csharp +// src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs +using System.Diagnostics; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class FilesSettingsTabViewModel : ViewModelBase +{ + private readonly WorkerClient _worker; + + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _isBusy; + + public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System); + public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning); + public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent); + + public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker; + + [RelayCommand] + private async Task RestoreDefaultAgents() + { + IsBusy = true; StatusMessage = ""; + try + { + var r = await _worker.RestoreDefaultAgentsAsync(); + if (r is null) StatusMessage = "Worker offline."; + else if (r.Copied == 0 && r.Skipped == 0) StatusMessage = "No default agents bundled."; + else if (r.Copied == 0) StatusMessage = "All default agents already present."; + else StatusMessage = $"Restored {r.Copied} default agent(s)."; + await _worker.RefreshAgentsAsync(); + } + catch (Exception ex) { StatusMessage = $"Restore failed: {ex.Message}"; } + finally { IsBusy = false; } + } + + [RelayCommand] + private void OpenPrompt(string? kindName) + { + if (!Enum.TryParse(kindName, ignoreCase: true, out var kind)) return; + try + { + PromptFiles.EnsureExists(kind); + var path = PromptFiles.PathFor(kind); + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + } + catch (Exception ex) { StatusMessage = $"Open failed: {ex.Message}"; } + } +} +``` + +- [ ] **Step 4: Slim down `SettingsModalViewModel` to a coordinator** + +Replace the body of `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`: + +```csharp +using System.Diagnostics; +using System.IO; +using System.Reflection; +using ClaudeDo.Data; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals.Settings; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +public sealed partial class SettingsModalViewModel : ViewModelBase +{ + private readonly WorkerClient _worker; + + public GeneralSettingsTabViewModel General { get; } + public WorktreesSettingsTabViewModel Worktrees { get; } + public FilesSettingsTabViewModel Files { get; } + public PrimeClaudeTabViewModel Prime { get; } // added in Task 13 + + [ObservableProperty] private string _validationError = ""; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _statusMessage = ""; + + public Action? CloseAction { get; set; } + + public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime) + { + _worker = worker; + General = new GeneralSettingsTabViewModel(); + Worktrees = new WorktreesSettingsTabViewModel(worker); + Files = new FilesSettingsTabViewModel(worker); + Prime = prime; + } + + public async Task LoadAsync() + { + IsBusy = true; + try + { + var dto = await _worker.GetAppSettingsAsync(); + if (dto is not null) + { + General.DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? ""; + General.DefaultModel = dto.DefaultModel ?? "sonnet"; + General.DefaultMaxTurns = dto.DefaultMaxTurns; + General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto"; + Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; + Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot; + Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; + Worktrees.WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays; + } + else StatusMessage = "Worker offline — settings read-only."; + + await Prime.LoadAsync(); + } + finally { IsBusy = false; } + } + + [RelayCommand] + private async Task Save() + { + var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate(); + if (err is not null) { ValidationError = err; return; } + ValidationError = ""; + + IsBusy = true; + try + { + var dto = new AppSettingsDto( + General.DefaultClaudeInstructions ?? "", + General.DefaultModel ?? "sonnet", + General.DefaultMaxTurns, + General.DefaultPermissionMode ?? "auto", + Worktrees.WorktreeStrategy ?? "sibling", + string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot, + Worktrees.WorktreeAutoCleanupEnabled, + Worktrees.WorktreeAutoCleanupDays); + await _worker.UpdateAppSettingsAsync(dto); + await Prime.SaveAsync(); + CloseAction?.Invoke(); + } + catch (Exception ex) { StatusMessage = $"Save failed: {ex.Message}"; } + finally { IsBusy = false; } + } + + [RelayCommand] private void Cancel() => CloseAction?.Invoke(); +} +``` + +- [ ] **Step 5: Build UI to verify shape (it will fail until Task 13 supplies `PrimeClaudeTabViewModel`)** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: compile error pointing at `PrimeClaudeTabViewModel`. This is intentional — Task 13 fills it in. + +- [ ] **Step 6: Do not commit yet** + +Wait for Task 13 to make the UI compile, then commit Tasks 12 + 13 together. + +--- + +## Task 13: Add `PrimeClaudeTabViewModel` + row VM (TDD) + +**Files:** +- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` +- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs` +- Create: `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +// 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 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; } + } + + [Fact] + public async Task Load_Populates_Rows() + { + var api = new FakeApi(); + api.Stored.Add(new PrimeScheduleDto( + Guid.NewGuid(), new DateOnly(2026,5,1), new DateOnly(2026,5,31), + new TimeSpan(7,0,0), true, true, null, null)); + 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].WorkdaysOnly); + Assert.Equal(new TimeSpan(7,0,0), vm.Rows[0].TimeOfDay); + } + + [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(new PrimeScheduleDto(keptId, new(2026,5,1), new(2026,5,31), new(7,0,0), true, true, null, null)); + api.Stored.Add(new PrimeScheduleDto(deletedId, new(2026,5,1), new(2026,5,31), new(8,0,0), true, true, null, null)); + + 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); + // 2 upserts: the kept (existing) row + the newly added row + Assert.Equal(2, api.Upserts.Count); + } + + [Fact] + public void Validate_Reports_StartAfterEnd() + { + var vm = new PrimeClaudeTabViewModel(new FakeApi()); + vm.AddScheduleCommand.Execute(null); + vm.Rows[0].StartDate = new DateOnly(2026, 6, 1); + vm.Rows[0].EndDate = new DateOnly(2026, 5, 1); + Assert.NotNull(vm.Validate()); + } +} +``` + +- [ ] **Step 2: Run tests, confirm failure (compile error)** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter "FullyQualifiedName~PrimeClaudeTabViewModelTests"` +Expected: compile error. + +- [ ] **Step 3: Implement the row VM** + +```csharp +// 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 +{ + public Guid Id { get; } + public bool IsExisting { get; } + + [ObservableProperty] private bool _enabled; + [ObservableProperty] private DateOnly _startDate; + [ObservableProperty] private DateOnly _endDate; + [ObservableProperty] private TimeSpan _timeOfDay; + [ObservableProperty] private bool _workdaysOnly; + [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; + StartDate = dto.StartDate; + EndDate = dto.EndDate; + TimeOfDay = dto.TimeOfDay; + WorkdaysOnly = dto.WorkdaysOnly; + LastRunAt = dto.LastRunAt; + } + + public PrimeScheduleDto ToDto() => + new(Id, StartDate, EndDate, TimeOfDay, WorkdaysOnly, Enabled, LastRunAt, null); +} +``` + +- [ ] **Step 4: Add `IPrimeScheduleApi` and adapter on `WorkerClient`** + +Add abstraction (avoids coupling tests to SignalR): + +```csharp +// src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs +namespace ClaudeDo.Ui.Services; + +public interface IPrimeScheduleApi +{ + Task> ListAsync(); + Task UpsertAsync(PrimeScheduleDto dto); + Task DeleteAsync(Guid id); +} + +public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi +{ + private readonly WorkerClient _client; + public WorkerPrimeScheduleApi(WorkerClient client) => _client = client; + public Task> ListAsync() => _client.GetPrimeSchedulesAsync(); + public Task UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto); + public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id); +} +``` + +- [ ] **Step 5: Implement the tab VM** + +```csharp +// src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs +using System.Collections.ObjectModel; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +public sealed partial class PrimeClaudeTabViewModel : ViewModelBase +{ + private readonly IPrimeScheduleApi _api; + private readonly HashSet _initialIds = new(); + + public ObservableCollection Rows { get; } = new(); + + public PrimeClaudeTabViewModel(IPrimeScheduleApi api) => _api = api; + + public async Task LoadAsync() + { + Rows.Clear(); + _initialIds.Clear(); + var list = await _api.ListAsync(); + foreach (var dto in list) + { + Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: true)); + _initialIds.Add(dto.Id); + } + } + + public string? Validate() + { + foreach (var r in Rows) + { + if (r.StartDate > r.EndDate) + return $"Schedule {r.TimeOfDay:hh\\:mm}: start date is after end date."; + if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1)) + return "Time must be between 00:00 and 23:59."; + } + return null; + } + + public async Task SaveAsync() + { + var keepIds = Rows.Select(r => r.Id).ToHashSet(); + foreach (var removed in _initialIds.Where(id => !keepIds.Contains(id)).ToList()) + await _api.DeleteAsync(removed); + foreach (var r in Rows) + await _api.UpsertAsync(r.ToDto()); + _initialIds.Clear(); + foreach (var id in keepIds) _initialIds.Add(id); + } + + [RelayCommand] + private void AddSchedule() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var dto = new PrimeScheduleDto( + Id: Guid.NewGuid(), + StartDate: today, + EndDate: today.AddDays(30), + TimeOfDay: new TimeSpan(7, 0, 0), + WorkdaysOnly: true, + Enabled: true, + LastRunAt: null, + PromptOverride: null); + Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false)); + } + + [RelayCommand] + private void RemoveSchedule(PrimeScheduleRowViewModel? row) + { + if (row is null) return; + Rows.Remove(row); + } + + public void ApplyFiredEvent(PrimeFiredEvent evt) + { + var row = Rows.FirstOrDefault(r => r.Id == evt.ScheduleId); + if (row is null) return; + if (evt.Success) row.LastRunAt = evt.FiredAt; + } +} +``` + +- [ ] **Step 6: Run tests, confirm pass** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter "FullyQualifiedName~PrimeClaudeTabViewModelTests"` +Expected: 4 passed. + +- [ ] **Step 7: Build UI** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: build succeeds (this also clears the Task 12 break). + +- [ ] **Step 8: Commit Tasks 12 + 13 together** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Modals/Settings/ src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs +git commit -m "feat(ui): split SettingsModalViewModel into per-tab VMs + add PrimeClaudeTabViewModel" +``` + +--- + +## Task 14: Refactor `SettingsModalView.axaml` to TabControl + add Prime Claude tab + +**Files:** +- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` + +This is a substantial XAML rewrite. The body changes from a `ScrollViewer` of `StackPanels` to a `TabControl` with four `TabItem`s. Bindings change from `{Binding DefaultModel}` to `{Binding General.DefaultModel}`, etc. + +- [ ] **Step 1: Replace the body `ScrollViewer` (lines 83–283 in current file) with a `TabControl`** + +Replace the entire ` ... ` block with the structure below. Keep the Title bar (Grid.Row=0) and Footer (Grid.Row=2) unchanged. + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +