2425 lines
88 KiB
Markdown
2425 lines
88 KiB
Markdown
# 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<PrimeScheduleEntity>
|
||
{
|
||
public void Configure(EntityTypeBuilder<PrimeScheduleEntity> 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<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
|
||
```
|
||
|
||
- [ ] **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/<timestamp>_AddPrimeSchedules.cs` (auto-generated)
|
||
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_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 `<timestamp>_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<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default) =>
|
||
await _context.PrimeSchedules.AsNoTracking()
|
||
.OrderBy(s => s.StartDate).ThenBy(s => s.TimeOfDay)
|
||
.ToListAsync(ct);
|
||
|
||
public async Task<PrimeScheduleEntity?> 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<PrimeRunOutcome> 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<PrimeRunner> _logger;
|
||
|
||
public PrimeRunner(IClaudeProcess claude, ILogger<PrimeRunner> logger)
|
||
{
|
||
_claude = claude;
|
||
_logger = logger;
|
||
}
|
||
|
||
public async Task<PrimeRunOutcome> 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<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 (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<Guid> FiredIds { get; } = new();
|
||
public Task<PrimeRunOutcome> 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<PrimeScheduler>.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<PrimeScheduler>.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<PrimeScheduler>.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<bool> 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<ClaudeDoDbContext> _dbFactory;
|
||
private readonly IPrimeRunner _runner;
|
||
private readonly IPrimeClock _clock;
|
||
private readonly IPrimeScheduleSignal _signal;
|
||
private readonly IPrimeBroadcaster _broadcaster;
|
||
private readonly PrimeSchedulerOptions _options;
|
||
private readonly ILogger<PrimeScheduler> _logger;
|
||
|
||
public PrimeScheduler(
|
||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||
IPrimeRunner runner,
|
||
IPrimeClock clock,
|
||
IPrimeScheduleSignal signal,
|
||
IPrimeBroadcaster broadcaster,
|
||
PrimeSchedulerOptions options,
|
||
ILogger<PrimeScheduler> 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<IReadOnlyList<PrimeScheduleDto>> 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<List<PrimeScheduleDto>> 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<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,
|
||
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<QueueService>());` (around line 79), add:
|
||
|
||
```csharp
|
||
// Prime Claude
|
||
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
|
||
builder.Services.AddSingleton<PrimeScheduleSignal>();
|
||
builder.Services.AddSingleton<IPrimeScheduleSignal>(sp => sp.GetRequiredService<PrimeScheduleSignal>());
|
||
builder.Services.AddSingleton<IPrimeRunner, PrimeRunner>();
|
||
builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
|
||
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
||
builder.Services.AddHostedService<PrimeScheduler>();
|
||
```
|
||
|
||
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<PrimeFiredEvent>? PrimeFired;
|
||
```
|
||
|
||
In the constructor, after the existing `_hub.On<...>` registrations, add:
|
||
|
||
```csharp
|
||
_hub.On<Guid, bool, string, DateTimeOffset>("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<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
|
||
{
|
||
try { return await _hub.InvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules"); }
|
||
catch { return new List<PrimeScheduleDto>(); }
|
||
}
|
||
|
||
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
|
||
{
|
||
try { return await _hub.InvokeAsync<PrimeScheduleDto>("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<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
|
||
public IReadOnlyList<string> 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<string> 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<PromptKind>(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<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; }
|
||
}
|
||
|
||
[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<List<PrimeScheduleDto>> ListAsync();
|
||
Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto);
|
||
Task DeleteAsync(Guid id);
|
||
}
|
||
|
||
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
|
||
{
|
||
private readonly WorkerClient _client;
|
||
public WorkerPrimeScheduleApi(WorkerClient client) => _client = client;
|
||
public Task<List<PrimeScheduleDto>> ListAsync() => _client.GetPrimeSchedulesAsync();
|
||
public Task<PrimeScheduleDto?> 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<Guid> _initialIds = new();
|
||
|
||
public ObservableCollection<PrimeScheduleRowViewModel> 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 `<ScrollViewer Grid.Row="1" ...> ... </ScrollViewer>` block with the structure below. Keep the Title bar (Grid.Row=0) and Footer (Grid.Row=2) unchanged.
|
||
|
||
```xml
|
||
<TabControl Grid.Row="1" Padding="20,12" TabStripPlacement="Top">
|
||
|
||
<!-- GENERAL -->
|
||
<TabItem Header="General">
|
||
<ScrollViewer>
|
||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||
<StackPanel Spacing="4">
|
||
<TextBlock Classes="field-label" Text="Default instructions"/>
|
||
<TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110"
|
||
Watermark="Baseline instructions applied to every task"
|
||
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
|
||
</StackPanel>
|
||
<Grid ColumnDefinitions="*,12,*,12,*">
|
||
<StackPanel Grid.Column="0" Spacing="4">
|
||
<TextBlock Classes="field-label" Text="Model"/>
|
||
<ComboBox ItemsSource="{Binding General.Models}"
|
||
SelectedItem="{Binding General.DefaultModel, Mode=TwoWay}"
|
||
HorizontalAlignment="Stretch"/>
|
||
</StackPanel>
|
||
<StackPanel Grid.Column="2" Spacing="4">
|
||
<TextBlock Classes="field-label" Text="Max turns"/>
|
||
<NumericUpDown Value="{Binding General.DefaultMaxTurns, Mode=TwoWay}"
|
||
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
|
||
</StackPanel>
|
||
<StackPanel Grid.Column="4" Spacing="4">
|
||
<TextBlock Classes="field-label" Text="Permission"/>
|
||
<ComboBox ItemsSource="{Binding General.PermissionModes}"
|
||
SelectedItem="{Binding General.DefaultPermissionMode, Mode=TwoWay}"
|
||
HorizontalAlignment="Stretch"/>
|
||
</StackPanel>
|
||
</Grid>
|
||
</StackPanel>
|
||
</ScrollViewer>
|
||
</TabItem>
|
||
|
||
<!-- WORKTREES -->
|
||
<TabItem Header="Worktrees">
|
||
<ScrollViewer>
|
||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||
<Grid ColumnDefinitions="*,12,2*">
|
||
<StackPanel Grid.Column="0" Spacing="4">
|
||
<TextBlock Classes="field-label" Text="Strategy"/>
|
||
<ComboBox ItemsSource="{Binding Worktrees.WorktreeStrategies}"
|
||
SelectedItem="{Binding Worktrees.WorktreeStrategy, Mode=TwoWay}"
|
||
HorizontalAlignment="Stretch"/>
|
||
</StackPanel>
|
||
<StackPanel Grid.Column="2" Spacing="4">
|
||
<TextBlock Classes="field-label" Text="Central worktree root"/>
|
||
<TextBox Text="{Binding Worktrees.CentralWorktreeRoot, Mode=TwoWay}"
|
||
Watermark="e.g. C:\worktrees"/>
|
||
</StackPanel>
|
||
</Grid>
|
||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||
<CheckBox IsChecked="{Binding Worktrees.WorktreeAutoCleanupEnabled, Mode=TwoWay}"
|
||
Content="Auto-cleanup finished worktrees after"
|
||
VerticalAlignment="Center"/>
|
||
<NumericUpDown Value="{Binding Worktrees.WorktreeAutoCleanupDays, Mode=TwoWay}"
|
||
Width="130" Minimum="1" Maximum="365" Increment="1" FormatString="0"
|
||
IsEnabled="{Binding Worktrees.WorktreeAutoCleanupEnabled}"/>
|
||
<TextBlock Text="days" VerticalAlignment="Center"
|
||
Foreground="{DynamicResource TextDimBrush}"/>
|
||
</StackPanel>
|
||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
|
||
<StackPanel Spacing="8">
|
||
<Button Content="Cleanup finished worktrees"
|
||
Command="{Binding Worktrees.CleanupWorktreesCommand}"
|
||
HorizontalAlignment="Left"/>
|
||
<StackPanel>
|
||
<Button Content="Force-remove all worktrees" Classes="danger"
|
||
Command="{Binding Worktrees.RequestResetConfirmCommand}"
|
||
HorizontalAlignment="Left"
|
||
IsVisible="{Binding !Worktrees.ShowResetConfirm}"/>
|
||
<Border BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
|
||
CornerRadius="6" Padding="12,10"
|
||
IsVisible="{Binding Worktrees.ShowResetConfirm}">
|
||
<StackPanel Spacing="8">
|
||
<TextBlock Text="Remove ALL worktrees? Uncommitted work will be lost."
|
||
Foreground="{DynamicResource TextBrush}" TextWrapping="Wrap"/>
|
||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||
<Button Content="Cancel" Command="{Binding Worktrees.CancelResetConfirmCommand}"/>
|
||
<Button Content="Remove All" Classes="danger"
|
||
Command="{Binding Worktrees.ConfirmResetAllCommand}"/>
|
||
</StackPanel>
|
||
</StackPanel>
|
||
</Border>
|
||
</StackPanel>
|
||
</StackPanel>
|
||
<TextBlock Text="{Binding Worktrees.StatusMessage}"
|
||
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
|
||
IsVisible="{Binding Worktrees.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||
</StackPanel>
|
||
</ScrollViewer>
|
||
</TabItem>
|
||
|
||
<!-- FILES -->
|
||
<TabItem Header="Files">
|
||
<ScrollViewer>
|
||
<StackPanel Spacing="14" Margin="0,8,0,0">
|
||
<StackPanel Spacing="6">
|
||
<TextBlock Classes="section-label" Text="AGENTS"/>
|
||
<TextBlock Text="Restore bundled default agents. Existing files are not overwritten."
|
||
FontSize="11" TextWrapping="Wrap"
|
||
Foreground="{DynamicResource TextDimBrush}"/>
|
||
<Button Content="Restore default agents"
|
||
Command="{Binding Files.RestoreDefaultAgentsCommand}"
|
||
IsEnabled="{Binding !Files.IsBusy}"
|
||
HorizontalAlignment="Left"/>
|
||
</StackPanel>
|
||
<StackPanel Spacing="6">
|
||
<TextBlock Classes="section-label" Text="PROMPTS"/>
|
||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
|
||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="System" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/>
|
||
<Button Grid.Row="0" Grid.Column="2" Content="Open in editor"
|
||
Command="{Binding Files.OpenPromptCommand}" CommandParameter="System"/>
|
||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Planning" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono" Text="{Binding Files.PlanningPromptPath}" VerticalAlignment="Center"/>
|
||
<Button Grid.Row="1" Grid.Column="2" Content="Open in editor"
|
||
Command="{Binding Files.OpenPromptCommand}" CommandParameter="Planning"/>
|
||
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Agent" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono" Text="{Binding Files.AgentPromptPath}" VerticalAlignment="Center"/>
|
||
<Button Grid.Row="2" Grid.Column="2" Content="Open in editor"
|
||
Command="{Binding Files.OpenPromptCommand}" CommandParameter="Agent"/>
|
||
</Grid>
|
||
</StackPanel>
|
||
<TextBlock Text="{Binding Files.StatusMessage}"
|
||
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
|
||
IsVisible="{Binding Files.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||
</StackPanel>
|
||
</ScrollViewer>
|
||
</TabItem>
|
||
|
||
<!-- PRIME CLAUDE -->
|
||
<TabItem Header="Prime Claude">
|
||
<ScrollViewer>
|
||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||
<TextBlock TextWrapping="Wrap" FontSize="11"
|
||
Foreground="{DynamicResource TextDimBrush}"
|
||
Text="Prime your Claude usage window each morning by firing a single non-interactive `ping` call 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."/>
|
||
<ItemsControl ItemsSource="{Binding Prime.Rows}">
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate x:DataType="settings:PrimeScheduleRowViewModel">
|
||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
||
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
|
||
Background="{DynamicResource DeepBrush}">
|
||
<Grid ColumnDefinitions="Auto,*,*,Auto,Auto,Auto,Auto" ColumnSpacing="8">
|
||
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||
<CalendarDatePicker Grid.Column="1" SelectedDate="{Binding StartDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}"
|
||
VerticalAlignment="Center"/>
|
||
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding EndDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}"
|
||
VerticalAlignment="Center"/>
|
||
<TextBox Grid.Column="3" Width="64" Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
|
||
VerticalAlignment="Center"/>
|
||
<CheckBox Grid.Column="4" Content="Mon–Fri" IsChecked="{Binding WorkdaysOnly, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Column="5" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
|
||
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
|
||
MinWidth="80"/>
|
||
<Button Grid.Column="6" Content="✕"
|
||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
</Grid>
|
||
</Border>
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
<Button Content="+ Add schedule" Command="{Binding Prime.AddScheduleCommand}" HorizontalAlignment="Left"/>
|
||
</StackPanel>
|
||
</ScrollViewer>
|
||
</TabItem>
|
||
|
||
</TabControl>
|
||
```
|
||
|
||
- [ ] **Step 2: Add the new XAML namespace + converters at the root**
|
||
|
||
In the `<Window ...>` opening tag add:
|
||
|
||
```xml
|
||
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
|
||
xmlns:conv="using:ClaudeDo.Ui.Converters"
|
||
```
|
||
|
||
In the `<Window.Resources>` block (add the block if absent, just before `<Window.Styles>`):
|
||
|
||
```xml
|
||
<Window.Resources>
|
||
<conv:DateOnlyToDateTimeConverter x:Key="DateOnlyToDateTime"/>
|
||
<conv:TimeSpanToHhmmConverter x:Key="TimeSpanToHhmm"/>
|
||
</Window.Resources>
|
||
```
|
||
|
||
- [ ] **Step 3: Move the validation/status footer strip below the TabControl**
|
||
|
||
Inside `Grid.Row="1"` of the outer Grid, the `TabControl` is the only child. Move the `<TextBlock Text="{Binding ValidationError}"...>` and the modal-level `StatusMessage` `TextBlock` so they sit between the TabControl and the existing Grid.Row=2 footer. Easiest implementation: change `Grid.Row="1"` to host a `DockPanel` with the warnings docked at bottom and the TabControl filling the rest:
|
||
|
||
```xml
|
||
<DockPanel Grid.Row="1">
|
||
<StackPanel DockPanel.Dock="Bottom" Margin="20,0,20,8" Spacing="2">
|
||
<TextBlock Text="{Binding ValidationError}" Foreground="{DynamicResource BloodBrush}" FontSize="11"
|
||
IsVisible="{Binding ValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||
<TextBlock Text="{Binding StatusMessage}" Foreground="{DynamicResource TextDimBrush}" FontSize="11"
|
||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||
</StackPanel>
|
||
<!-- TabControl from Step 1 goes here, without Grid.Row attribute -->
|
||
</DockPanel>
|
||
```
|
||
|
||
- [ ] **Step 4: Build UI**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||
Expected: build fails because the converters from Task 15 don't exist yet. Move on to Task 15.
|
||
|
||
- [ ] **Step 5: Do not commit yet** — combine with Task 15 + 16 + 17.
|
||
|
||
---
|
||
|
||
## Task 15: Add `DateOnly`/`TimeSpan` value converters
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Ui/Converters/DateOnlyToDateTimeConverter.cs`
|
||
- Create: `src/ClaudeDo.Ui/Converters/TimeSpanToHhmmConverter.cs`
|
||
|
||
Avalonia 12's `CalendarDatePicker` binds `DateTime?`, not `DateOnly`. The `TextBox` for time-of-day formats as `HH:mm`.
|
||
|
||
- [ ] **Step 1: DateOnly ↔ DateTime converter**
|
||
|
||
```csharp
|
||
// src/ClaudeDo.Ui/Converters/DateOnlyToDateTimeConverter.cs
|
||
using System.Globalization;
|
||
using Avalonia.Data.Converters;
|
||
|
||
namespace ClaudeDo.Ui.Converters;
|
||
|
||
public sealed class DateOnlyToDateTimeConverter : IValueConverter
|
||
{
|
||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||
{
|
||
if (value is DateOnly d)
|
||
return d.ToDateTime(TimeOnly.MinValue);
|
||
return null;
|
||
}
|
||
|
||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||
{
|
||
if (value is DateTime dt)
|
||
return DateOnly.FromDateTime(dt);
|
||
if (value is DateTimeOffset dto)
|
||
return DateOnly.FromDateTime(dto.LocalDateTime);
|
||
return DateOnly.FromDateTime(DateTime.Today);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: TimeSpan ↔ "HH:mm" converter**
|
||
|
||
```csharp
|
||
// src/ClaudeDo.Ui/Converters/TimeSpanToHhmmConverter.cs
|
||
using System.Globalization;
|
||
using Avalonia.Data.Converters;
|
||
|
||
namespace ClaudeDo.Ui.Converters;
|
||
|
||
public sealed class TimeSpanToHhmmConverter : IValueConverter
|
||
{
|
||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||
value is TimeSpan t ? $"{t.Hours:00}:{t.Minutes:00}" : "07:00";
|
||
|
||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||
{
|
||
if (value is not string s) return new TimeSpan(7, 0, 0);
|
||
var parts = s.Split(':');
|
||
if (parts.Length == 2 &&
|
||
int.TryParse(parts[0], out var h) && h is >= 0 and <= 23 &&
|
||
int.TryParse(parts[1], out var m) && m is >= 0 and <= 59)
|
||
return new TimeSpan(h, m, 0);
|
||
return new TimeSpan(7, 0, 0);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build UI**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 4: Do not commit yet** — combine with Task 14 + 16 + 17.
|
||
|
||
---
|
||
|
||
## Task 16: Add About modal + Help menu wiring
|
||
|
||
**Files:**
|
||
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs`
|
||
- Create: `src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml`
|
||
- Create: `src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml.cs`
|
||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` (or wherever the Help menu commands live)
|
||
|
||
- [ ] **Step 1: Create the About VM (mirrors today's About section content)**
|
||
|
||
```csharp
|
||
// src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs
|
||
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.Reflection;
|
||
using ClaudeDo.Data;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
|
||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||
|
||
public sealed partial class AboutModalViewModel : ViewModelBase
|
||
{
|
||
public string AppVersion { get; } =
|
||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
||
public string DataFolderPath { get; } = Paths.AppDataRoot();
|
||
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
|
||
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||
|
||
public Action? CloseAction { get; set; }
|
||
|
||
[RelayCommand] private void Close() => CloseAction?.Invoke();
|
||
|
||
[RelayCommand]
|
||
private void OpenPath(string? path)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(path)) return;
|
||
try
|
||
{
|
||
var target = File.Exists(path) ? path : (Directory.Exists(path) ? path : null);
|
||
if (target is null) return;
|
||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{target}\"") { UseShellExecute = true });
|
||
}
|
||
catch { /* ignore */ }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create the View**
|
||
|
||
```xml
|
||
<!-- src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml -->
|
||
<Window xmlns="https://github.com/avaloniaui"
|
||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||
x:Class="ClaudeDo.Ui.Views.Modals.AboutModalView"
|
||
x:DataType="vm:AboutModalViewModel"
|
||
Title="About ClaudeDo"
|
||
Width="480" Height="280"
|
||
SystemDecorations="None"
|
||
ExtendClientAreaToDecorationsHint="True"
|
||
WindowStartupLocation="CenterOwner"
|
||
Background="{DynamicResource SurfaceBrush}">
|
||
<Window.KeyBindings>
|
||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||
</Window.KeyBindings>
|
||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
|
||
<Grid RowDefinitions="36,*,52">
|
||
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
|
||
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
|
||
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||
<TextBlock Text="ABOUT" FontFamily="{DynamicResource MonoFont}" FontSize="11"
|
||
LetterSpacing="1.4" Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
|
||
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
|
||
Command="{Binding CloseCommand}" VerticalAlignment="Center"/>
|
||
</Grid>
|
||
</Border>
|
||
<ScrollViewer Grid.Row="1" Padding="20,16">
|
||
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10">
|
||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" FontFamily="{DynamicResource MonoFont}" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Data" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding DataFolderPath}" FontFamily="{DynamicResource MonoFont}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||
<Button Grid.Row="1" Grid.Column="2" Content="Open" Command="{Binding OpenPathCommand}" CommandParameter="{Binding DataFolderPath}"/>
|
||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Logs" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding LogsFolderPath}" FontFamily="{DynamicResource MonoFont}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||
<Button Grid.Row="2" Grid.Column="2" Content="Open" Command="{Binding OpenPathCommand}" CommandParameter="{Binding LogsFolderPath}"/>
|
||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Config" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding WorkerConfigPath}" FontFamily="{DynamicResource MonoFont}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||
<Button Grid.Row="3" Grid.Column="2" Content="Open" Command="{Binding OpenPathCommand}" CommandParameter="{Binding WorkerConfigPath}"/>
|
||
</Grid>
|
||
</ScrollViewer>
|
||
<Border Grid.Row="2" Background="{DynamicResource DeepBrush}"
|
||
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0">
|
||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="16,0">
|
||
<Button Content="Close" Command="{Binding CloseCommand}" MinWidth="90"/>
|
||
</StackPanel>
|
||
</Border>
|
||
</Grid>
|
||
</Border>
|
||
</Window>
|
||
```
|
||
|
||
- [ ] **Step 3: Code-behind for the About view**
|
||
|
||
```csharp
|
||
// src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml.cs
|
||
using Avalonia.Controls;
|
||
using Avalonia.Markup.Xaml;
|
||
|
||
namespace ClaudeDo.Ui.Views.Modals;
|
||
|
||
public partial class AboutModalView : Window
|
||
{
|
||
public AboutModalView() => InitializeComponent();
|
||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire Help menu**
|
||
|
||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, locate the existing `<MenuItem Header="Help">` block and add a new child menu item:
|
||
|
||
```xml
|
||
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
|
||
```
|
||
|
||
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` (the VM bound as `MainWindow`'s DataContext root), add:
|
||
|
||
```csharp
|
||
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
||
|
||
[RelayCommand]
|
||
private async Task OpenAbout()
|
||
{
|
||
var vm = new AboutModalViewModel();
|
||
if (ShowAboutModal is not null) await ShowAboutModal(vm);
|
||
}
|
||
```
|
||
|
||
(If the existing Help menu commands like `CheckForUpdatesCommand` live on a different VM, follow the same wiring pattern there.)
|
||
|
||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, where other modals are wired (search for `ShowSettingsModal` or similar), add:
|
||
|
||
```csharp
|
||
viewModel.ShowAboutModal = async vm =>
|
||
{
|
||
var dlg = new AboutModalView { DataContext = vm };
|
||
var tcs = new TaskCompletionSource<bool>();
|
||
vm.CloseAction = () => { dlg.Close(); tcs.TrySetResult(true); };
|
||
await dlg.ShowDialog(this);
|
||
};
|
||
```
|
||
|
||
(Pattern matches the existing settings modal wiring — open `MainWindow.axaml.cs` and copy the `ShowSettingsModal` setup as a template.)
|
||
|
||
- [ ] **Step 5: Build UI**
|
||
|
||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 6: Do not commit yet** — combine with Task 14 + 15 + 17.
|
||
|
||
---
|
||
|
||
## Task 17: Footer notification — `StatusBarViewModel.PrimeStatus`
|
||
|
||
**Files:**
|
||
- Modify: existing status bar VM (find via `grep -rn "PrimeStatus\|ConnectionText\|WorkerLogText"` — the host is likely `IslandsShellViewModel`)
|
||
- Modify: the matching status bar XAML (search for `IsWorkerLogVisible` to locate it)
|
||
|
||
The host is `IslandsShellViewModel` per the existing `WorkerLogText` / `IsWorkerLogVisible` properties.
|
||
|
||
- [ ] **Step 1: Add fields + subscription on `IslandsShellViewModel`**
|
||
|
||
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
|
||
|
||
```csharp
|
||
[ObservableProperty] private string? _primeStatus;
|
||
private readonly System.Timers.Timer _primeStatusTimer = new(5_000) { AutoReset = false };
|
||
```
|
||
|
||
In the constructor (where `Worker` is wired up), add:
|
||
|
||
```csharp
|
||
if (Worker is not null)
|
||
{
|
||
Worker.PrimeFired += OnPrimeFired;
|
||
}
|
||
_primeStatusTimer.Elapsed += (_, _) =>
|
||
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
|
||
```
|
||
|
||
Add the handler:
|
||
|
||
```csharp
|
||
private void OnPrimeFired(PrimeFiredEvent evt)
|
||
{
|
||
var when = evt.FiredAt.LocalDateTime.ToString("HH:mm");
|
||
PrimeStatus = evt.Success
|
||
? $"✓ Primed Claude at {when}"
|
||
: $"⚠ Prime failed: {evt.Message}";
|
||
_primeStatusTimer.Stop();
|
||
_primeStatusTimer.Start();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Surface `PrimeStatus` in the status bar XAML**
|
||
|
||
Find the existing status bar layout (likely in `MainWindow.axaml` or a dedicated status area within `IslandsShellView` content). Add a `TextBlock` near the connection text:
|
||
|
||
```xml
|
||
<TextBlock Text="{Binding PrimeStatus}"
|
||
Foreground="{DynamicResource TextDimBrush}"
|
||
FontSize="11"
|
||
VerticalAlignment="Center"
|
||
Margin="12,0,0,0"
|
||
IsVisible="{Binding PrimeStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||
```
|
||
|
||
- [ ] **Step 3: Build + final commit**
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||
```
|
||
|
||
Expected: build succeeds.
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/ src/ClaudeDo.Ui.csproj 2>/dev/null; \
|
||
git add src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml \
|
||
src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml \
|
||
src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml.cs \
|
||
src/ClaudeDo.Ui/Views/MainWindow.axaml \
|
||
src/ClaudeDo.Ui/Views/MainWindow.axaml.cs \
|
||
src/ClaudeDo.Ui/Converters/DateOnlyToDateTimeConverter.cs \
|
||
src/ClaudeDo.Ui/Converters/TimeSpanToHhmmConverter.cs \
|
||
src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs \
|
||
src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
|
||
git commit -m "feat(ui): tabbed Settings + Prime Claude tab + About modal + footer prime status"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Final smoke test (manual)
|
||
|
||
- [ ] **Step 1: Full solution build**
|
||
|
||
Run sequentially:
|
||
|
||
```bash
|
||
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
|
||
```
|
||
|
||
Expected: all succeed.
|
||
|
||
- [ ] **Step 2: Run all tests**
|
||
|
||
```bash
|
||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
|
||
```
|
||
|
||
Expected: all green. New test files contribute additional cases.
|
||
|
||
- [ ] **Step 3: Manual verification (UI)**
|
||
|
||
Launch the app. Verify:
|
||
|
||
1. Open Settings → see 4 tabs: General, Worktrees, Files, Prime Claude.
|
||
2. Each existing field still loads its current value and saves correctly (round-trip Save → reopen).
|
||
3. Prime Claude tab loads empty initially.
|
||
4. "+ Add schedule" appends a row with today / today+30 / 07:00 / Mon–Fri / Enabled.
|
||
5. Edit start/end dates, time, weekday flag. Save. Reopen → values persist.
|
||
6. Set a schedule with `TimeOfDay = (now + 1 minute)` and `WorkdaysOnly = false`, `StartDate = today`, `EndDate = today`. Save and close.
|
||
7. Within ~1 minute the status bar shows `✓ Primed Claude at HH:mm` for ~5 seconds.
|
||
8. Reopen Settings → Prime Claude row's "last run" column shows today's timestamp.
|
||
9. Help menu → About… opens the new modal; Open buttons work; Close (or Esc) dismisses.
|
||
10. The old About section is gone from Settings.
|
||
|
||
- [ ] **Step 4: Final commit (if any cleanup)**
|
||
|
||
If manual testing surfaced no fixes, no further commit is needed. Otherwise:
|
||
|
||
```bash
|
||
git add -p
|
||
git commit -m "fix: <describe the manual-test finding>"
|
||
```
|
||
|
||
---
|
||
|
||
## Done
|
||
|
||
Goal: tabbed Settings, Prime Claude scheduling, About modal, footer notification.
|