# Tabbed Settings + Prime Claude — Design

**Date:** 2026-04-28
**Status:** Draft for review

## Goal

Two related UI changes:

1. Restructure the existing **Settings modal** from a single scrollable stack into a `TabControl` with focused tabs. Move the read-only "About" content out of Settings entirely, into a new modal accessible from the existing Help menu.
2. Add a new **Prime Claude** tab where the user defines date-bounded daily schedules. At each scheduled time, the worker fires a single non-interactive `claude -p "ping" --max-turns 1` call to start Claude's 5-hour usage window early — "priming" the day.

## Scope

### In scope
- Settings tabbed UI with 4 tabs: General, Worktrees, Files, Prime Claude.
- New About modal opened from `MainWindow` Help menu.
- New `PrimeSchedules` table, repository, EF migration.
- New `PrimeScheduler` background service (event-driven, no polling).
- New SignalR hub methods + client wiring.
- Footer notification on prime fire (success/failure) via `StatusBarView`.
- 30-minute catch-up window on app launch / wake.
- Tests: scheduler unit tests, tab VM tests.

### Out of scope
- Auto-start ClaudeDo at OS boot.
- Multiple pings per day per schedule.
- Per-schedule prompt customization (schema reserves the column for future use).
- Holiday / calendar integration.
- Toast notifications, sound, OS-level notifications.

## Settings tab layout

| Tab | Contents (existing sections, no field changes) |
|---|---|
| **General** | Claude Defaults: instructions, model, max turns, permission mode |
| **Worktrees** | Strategy, central root, auto-cleanup, Cleanup button, Force-remove confirm flow |
| **Files** | Agents (Restore default agents) + Prompts (System / Planning / Agent open-in-editor rows) |
| **Prime Claude** | New — schedule list + add button (see below) |

- Window stays 580×760, custom title bar preserved.
- Footer (Save / Cancel) preserved; Save iterates per-tab VMs.
- Status / validation strip stays above the footer.
- Tab strip uses the existing section-label style for headers (mono, 10pt, letter-spacing 1.4) so it visually matches the current aesthetic.

## About modal

New `AboutModalView` + `AboutModalViewModel`:
- Same 4 rows as today's About section: Version, Data folder, Logs folder, Worker config — each with an Open button.
- Compact dialog (~480×280), same chrome as `SettingsModalView`.
- Wired into `MainWindow` Help menu as a new `<MenuItem Header="About…">` next to "Check for updates".
- About content removed from `SettingsModalView` entirely (cleaner: not a setting).

## Prime Claude tab — UI

```
┌────────────────────────────────────────────────────────────────┐
│ 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 min of the target │
│ time, the ping fires immediately (catch-up window).             │
├────────────────────────────────────────────────────────────────┤
│ ☑  May 5, 2026 → Jun 30, 2026   07:00   Mon–Fri   last: today  ✕│
│ ☐  Jul 1, 2026 → Jul 7, 2026    09:30   All days  —             ✕│
├────────────────────────────────────────────────────────────────┤
│ [+ Add schedule]                                                │
└────────────────────────────────────────────────────────────────┘
```

Per-row controls:
- Enabled checkbox (`Enabled`)
- Start date picker (`StartDate`)
- End date picker (`EndDate`)
- Time-of-day field (`TimeOfDay`, 24h, e.g. `07:00`)
- Workdays-only checkbox (`WorkdaysOnly`)
- Last run label (`{LastRunAt:g}` or `—` if null)
- Delete button (✕, with inline confirm bar matching the Worktrees pattern)

`+ Add schedule` appends a new row pre-filled with: today, today + 30 days, `07:00`, `WorkdaysOnly = true`, `Enabled = true`.

Validation per row:
- `StartDate <= EndDate`
- `TimeOfDay` parses as `HH:mm`
- `EndDate >= today` (else mark row disabled-looking + tooltip "expired")

Persistence: rows save with the rest of the modal on **Save**. On Save, `PrimeClaudeTabViewModel` diffs in-memory rows against the loaded snapshot and emits one hub call per change: `UpsertPrimeSchedule` for new/edited rows, `DeletePrimeSchedule` for removed rows. Cancel discards in-memory edits. No per-row autosave.

## Data model

New EF Core entity `PrimeScheduleEntity` in `ClaudeDo.Data/Models/`:

```csharp
public class PrimeScheduleEntity
{
    public Guid Id { get; set; }
    public DateOnly StartDate { get; set; }
    public DateOnly EndDate { get; set; }
    public TimeSpan TimeOfDay { get; set; }     // local clock
    public bool WorkdaysOnly { get; set; }
    public bool Enabled { get; set; }
    public DateTimeOffset? LastRunAt { get; set; }
    public string? PromptOverride { get; set; }  // reserved, always null today
    public DateTimeOffset CreatedAt { get; set; }
}
```

- New `PrimeScheduleConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity>` in `Configuration/`.
- New repository `PrimeScheduleRepository` matching the existing async + CancellationToken pattern. Methods: `ListAsync`, `GetAsync(id)`, `UpsertAsync(entity)`, `DeleteAsync(id)`, `UpdateLastRunAsync(id, when)`.
- EF migration `AddPrimeSchedules` (auto-named per existing migration history).

## Worker scheduler — `PrimeScheduler`

New folder `ClaudeDo.Worker/Prime/`. Class hierarchy:

- `PrimeScheduler : BackgroundService` — event-driven loop.
- `IPrimeRunner` / `PrimeRunner` — fires the actual `claude -p "ping" --max-turns 1` call. Injected so tests can fake it.
- `IPrimeClock` / `PrimeClock` — `DateTimeOffset Now { get; }`. Faked in tests.
- `PrimeSchedulerOptions` — `CatchUpWindow = TimeSpan.FromMinutes(30)`. Hardcoded today; typed for swappability.

### Loop

```text
while not cancelled:
    next = ComputeNextDue(now)        # null if no enabled schedules
    if next is null:
        await wait-on-signal           # blocks until schedules change
        continue
    delay = max(0, next.At - now)
    try:
        await Task.Delay(delay, linkedToken)   # cancellable by signal
    catch OperationCanceledException:
        continue                       # schedules changed → recompute
    await Fire(next.Schedule)
```

`ComputeNextDue(now)`:
- For each enabled schedule:
  - Determine the next eligible date `d >= today` within `[StartDate, EndDate]`, honoring `WorkdaysOnly`.
  - Skip the day if `LastRunAt.LocalDate == today` (already fired today).
  - Build `target = d.At(TimeOfDay)` in local time.
  - Apply catch-up: if `target < now <= target + 30min` and not already fired today, target = `now` (fire immediately).
  - If `target < now` (past catch-up window) and `d == today`, advance `d` to next eligible date.
- Return the schedule with the smallest `target`.

### Signal source

`IPrimeScheduleSignal` — a thin abstraction wrapping a `CancellationTokenSource` reset. The hub calls `Signal()` on:
- App start (initial recompute is implicit — service first-run computes immediately).
- After `UpsertPrimeSchedule` / `DeletePrimeSchedule`.
- After a successful fire (so the next-due is recomputed without polling).

### Fire

`PrimeRunner.FireAsync(schedule, ct)`:
1. Resolve `claude` executable via existing `ClaudeProcess` discovery.
2. Spawn with `cwd = Paths.AppDataRoot()`, args `["-p", "ping", "--max-turns", "1"]`. No worktree, no task entity, no list/tag side effects.
3. Capture stdout/stderr; success = exit 0 within a 60s timeout.
4. On finish: `await PrimeScheduleRepository.UpdateLastRunAsync(id, now)`, append a one-line summary to `~/.todo-app/logs/prime.log`, broadcast `PrimeFired(success, message, timestamp)` via `HubBroadcaster`.

Failure modes (network, auth, executable missing) → broadcast a failure message; `LastRunAt` still stamped so the day doesn't keep retrying.

## SignalR / IPC

### Hub methods (`WorkerHub`)

```csharp
Task<IReadOnlyList<PrimeScheduleDto>> ListPrimeSchedules();
Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto);
Task DeletePrimeSchedule(Guid id);
```

DTO mirrors entity minus `CreatedAt` (server-managed).

### Hub events (broadcast)

```csharp
event PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
```

The `scheduleId` lets an open Settings modal update the matching row's `LastRunAt` without a full reload. No separate `PrimeSchedulesChanged` event — Settings is the only writer, so the modal's own VM state is authoritative until Save.

`WorkerClient` adds matching async methods + the event handler.

## UI wiring

### ViewModel split

`SettingsModalViewModel` stops holding field properties directly and becomes a coordinator:

```csharp
public sealed partial class SettingsModalViewModel
{
    public GeneralSettingsTabViewModel General { get; }
    public WorktreesSettingsTabViewModel Worktrees { get; }
    public FilesSettingsTabViewModel Files { get; }
    public PrimeClaudeTabViewModel Prime { get; }

    [RelayCommand] private async Task Save() { ... iterate tabs, call SaveAsync on each ... }
}
```

Each tab VM:
- Owns its observable properties.
- Has `Task LoadAsync()` and `Task SaveAsync()` (or returns a partial DTO the coordinator merges).
- Owns its own validation, surfaces `ValidationError`.

`PrimeClaudeTabViewModel`:
- `ObservableCollection<PrimeScheduleRowViewModel> Rows`
- `[RelayCommand] AddSchedule()` / `RemoveSchedule(id)`
- Subscribes to `WorkerClient.PrimeSchedulesChanged` / `PrimeFired` to keep rows fresh while modal is open.

### Footer notification

`StatusBarViewModel`:
- New `string? PrimeStatus` property.
- Subscribes to `WorkerClient.PrimeFired`.
- On event: set `PrimeStatus`, start a `DispatcherTimer` for 5s, clear on tick.
- `StatusBarView` gets a `TextBlock` bound to `PrimeStatus`, right-aligned, dim-foreground, only visible when non-empty.

Format: `"✓ Primed Claude at 07:01"` or `"⚠ Prime failed: <reason>"`.

### About wiring

- `MainWindowViewModel` adds `[RelayCommand] OpenAbout()` — opens `AboutModalView` via the existing dialog factory pattern.
- `MainWindow.axaml` Help menu gains `<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>`.

## Tests

### `ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`

Real SQLite, fake `IPrimeClock`, fake `IPrimeRunner`. Cases:
- Fires once at exact target time.
- Fires immediately on startup if within catch-up window.
- Skips firing if past catch-up window (waits for next eligible day).
- Honors `WorkdaysOnly` (no fire on Sat/Sun).
- Honors date range (no fire before StartDate, none after EndDate).
- Idempotent: doesn't double-fire if `LastRunAt` is today.
- Recomputes on signal (upsert mid-wait).
- Disabling a schedule mid-wait recomputes.

### `ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`

Cases:
- Add row appends with sensible defaults.
- Remove row removes from collection.
- Validation: StartDate > EndDate flags row as invalid.
- Save serializes all rows to repository in one batch.
- `PrimeFired` event updates the matching row's `LastRunAt`.

### `ClaudeDo.Ui.Tests/ViewModels/StatusBarViewModelTests.cs` (extend existing if present, else new)

- `PrimeFired` sets `PrimeStatus` and clears it after 5s (use a fake `IDispatcherTimer` or an injectable delay).

## Migration / rollout

- Single EF migration `AddPrimeSchedules`. Existing DBs upgrade on next launch via the existing migration runner (no manual step).
- No data backfill — table starts empty. Users add schedules manually via the new tab.
- Backwards compatibility for `AppSettingsEntity`: untouched.

## Risks & mitigations

| Risk | Mitigation |
|---|---|
| App is closed at scheduled time | 30 min catch-up on launch; explicit copy in tab explains the limitation. |
| Clock/timezone change while waiting | `Task.Delay` fires on monotonic time; recompute after each fire catches drift on next iteration. Acceptable for a 5h-window primer. |
| Claude CLI hangs | 60s timeout on the spawn; failure stamped + broadcast. |
| Multiple ClaudeDo instances on same machine | Out of scope (existing app already assumes single instance via fixed SignalR port). |
| User edits schedule while scheduler is mid-fire | Fire completes, then signal triggers recompute. No race — `UpdateLastRunAsync` is the last write. |

## Open questions

None at design time. Implementation may surface small details (e.g. exact Avalonia controls for date/time pickers — likely `CalendarDatePicker` + a `TextBox` masked to `HH:mm` since Avalonia 12 has no built-in TimePicker on all platforms).
