diff --git a/docs/superpowers/plans/2026-06-03-daily-prep-live-view.md b/docs/superpowers/plans/2026-06-03-daily-prep-live-view.md new file mode 100644 index 0000000..f54119c --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-daily-prep-live-view.md @@ -0,0 +1,517 @@ +# Daily Prep — Live Output View + Clear Day — 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:** Stream the daily-prep run's output into a live, human-readable view (a new mode in the Details island), and add a "Clear Day" button that empties MyDay. + +**Architecture:** The worker broadcasts `PrepStarted/PrepLine/PrepFinished` over SignalR (mirroring `TaskStarted/TaskMessage/TaskFinished`). `PrimeRunner` forwards each Claude stdout line instead of discarding it. The UI `WorkerClient` re-raises these as events; `DetailsIslandViewModel` gains a `PrepLog` + `IsPrepMode` panel rendered with the existing terminal renderer. A `ClearMyDay` hub method bulk-clears `IsMyDay`. MyDay header gets "Vorbereitungs-Log" and "Tag leeren" buttons. + +**Tech Stack:** .NET 8, ASP.NET Core SignalR, EF Core (SQLite), Avalonia + CommunityToolkit.Mvvm, xUnit. + +**Spec:** `docs/superpowers/specs/2026-06-03-daily-prep-live-view-design.md` + +--- + +## Build & test commands + +`.slnx` needs .NET 9; build/test individual csproj with `-c Release` (a running Worker may lock Debug). + +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +``` + +UI cannot be GUI-smoke-tested headlessly — note that explicitly where it applies; the human verifies visuals. + +## Reference anchors (verify before editing — line numbers drift) + +- `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs` — currently only `PrimeFiredAsync`. +- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs:13-57` — broadcast methods; `PrimeFired` at ~52-56. +- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs:31-79` — `FireAsync`; discard lambda at ~55-60; ctor at ~19-29. +- `src/ClaudeDo.Worker/Hub/WorkerHub.cs:542-549` — `RunDailyPrepNow` (uses `_broadcaster`); DailyNote CRUD at 559-583 (shows the db-context pattern this hub uses). +- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:19` — `TaskMessageEvent`; `:55` — `RunDailyPrepNowAsync`. +- `src/ClaudeDo.Ui/Services/WorkerClient.cs:99-122` — `TaskStarted/Finished/Message` hub.On; `:170-173` — `PrimeFired` hub.On (the pattern to copy). +- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `IsNotesMode` ~56, `Log` ~193, ctor/subscriptions ~272-337, `OnTaskMessage` ~339-363 (stdout→`StreamLineFormatter`→`Log`), `ShowNotes` ~478-483. +- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml:131-302` — body grid; task panel `IsVisible="{Binding !IsNotesMode}"`, notes panel `IsVisible="{Binding IsNotesMode}"`; `SessionTerminalView` embedded ~295. +- `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml:54-75` — `ItemsControl ItemsSource="{Binding Log}"` + the `LogLineViewModel` item template to reuse. +- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — `NotesRequested` ~29, `OpenNotesCommand`+`PrepareDayCommand` ~33-45, `ShowNotesRow`/`IsMyDayList` ~65-66, both set in `LoadForList` ~212-213. +- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml:69-84` — Notes + PrepareDay buttons (styling to copy). +- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs:199-201` — island event wiring; `:225` — `PrimeFired` subscription. +- Fakes to keep in sync: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (`FakeWorkerClient`). + +--- + +## Task 1: Worker — prep output broadcast + streaming + +**Files:** +- Modify: `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs` +- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` +- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs` + +- [ ] **Step 1: Write the failing test.** Extend `PrimeRunnerTests` with a fake `IPrimeBroadcaster` that records calls. The fake `IClaudeProcess` should invoke `onStdoutLine` with two sample lines and return `RunResult { ExitCode = 0, ResultMarkdown = "ok" }`. + +```csharp +[Fact] +public async Task FireAsync_streams_started_lines_and_finished() +{ + var broadcaster = new RecordingPrimeBroadcaster(); + var claude = new FakeClaudeProcess(emitLines: new[] { "{\"a\":1}", "{\"b\":2}" }, exitCode: 0, result: "ok"); + var runner = NewRunner(claude, broadcaster); // build with temp-sqlite dbFactory + fake clock + logger + broadcaster + var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null); + + var outcome = await runner.FireAsync(schedule, CancellationToken.None); + + Assert.True(outcome.Success); + Assert.Equal(1, broadcaster.StartedCount); + Assert.Equal(new[] { "{\"a\":1}", "{\"b\":2}" }, broadcaster.Lines); + Assert.Single(broadcaster.FinishedResults); + Assert.True(broadcaster.FinishedResults[0]); +} +``` + +`RecordingPrimeBroadcaster` implements `IPrimeBroadcaster`: `StartedCount`, `List Lines`, `List FinishedResults`, and a no-op `PrimeFiredAsync`. If the existing `FakeClaudeProcess` cannot emit lines, add an optional `emitLines` parameter that loops `await onStdoutLine(line)` before returning. + +- [ ] **Step 2: Run — expect FAIL** (interface methods + ctor param missing). + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter PrimeRunner +``` + +- [ ] **Step 3: Extend `IPrimeBroadcaster`:** + +```csharp +public interface IPrimeBroadcaster +{ + Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt); + Task PrepStartedAsync(); + Task PrepLineAsync(string line); + Task PrepFinishedAsync(bool success); +} +``` + +(Keep the existing `PrimeFiredAsync` signature exactly as it is in the current file.) + +- [ ] **Step 4: Implement in `HubBroadcaster`** (add next to `PrimeFired`): + +```csharp + public Task PrepStarted() => _hub.Clients.All.SendAsync("PrepStarted"); + public Task PrepLine(string line) => _hub.Clients.All.SendAsync("PrepLine", line); + public Task PrepFinished(bool success) => _hub.Clients.All.SendAsync("PrepFinished", success); + + Task IPrimeBroadcaster.PrepStartedAsync() => PrepStarted(); + Task IPrimeBroadcaster.PrepLineAsync(string line) => PrepLine(line); + Task IPrimeBroadcaster.PrepFinishedAsync(bool success) => PrepFinished(success); +``` + +(Match the existing explicit-interface style used for `PrimeFiredAsync`.) + +- [ ] **Step 5: Wire `PrimeRunner`.** Add `IPrimeBroadcaster _broadcaster` as a ctor param (and field). Rewrite the body of `FireAsync` after the gate check to: + +```csharp + if (!await _gate.WaitAsync(0, ct)) + return new PrimeRunOutcome(false, "Daily prep already running"); + + var success = false; + try + { + await _broadcaster.PrepStartedAsync(); + + var cwd = Paths.AppDataRoot(); + Directory.CreateDirectory(cwd); + + int maxTasks; + await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct)) + { + var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct); + maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; + } + + var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime); + var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today); + var args = DailyPrepPrompt.BuildArgs(MaxTurns); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(FireTimeout); + + var result = await _claude.RunAsync( + arguments: args, + prompt: prompt, + workingDirectory: cwd, + onStdoutLine: line => _broadcaster.PrepLineAsync(line), + ct: timeoutCts.Token); + + success = result.IsSuccess; + return success + ? new PrimeRunOutcome(true, "Daily prep complete") + : new PrimeRunOutcome(false, $"exit code {result.ExitCode}"); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Daily prep run failed"); + return new PrimeRunOutcome(false, ex.Message); + } + finally + { + await _broadcaster.PrepFinishedAsync(success); + _gate.Release(); + } +``` + +DI is unchanged: `AddSingleton()` resolves `IPrimeBroadcaster` (registered as `sp => sp.GetRequiredService()`). + +- [ ] **Step 6: Update existing `PrimeRunnerTests` ctor calls** to pass the recording broadcaster; build + run. + +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter PrimeRunner +``` + +- [ ] **Step 7: Commit.** + +```bash +git add src/ClaudeDo.Worker/Prime src/ClaudeDo.Worker/Hub/HubBroadcaster.cs tests/ClaudeDo.Worker.Tests/Prime +git commit -m "feat(daily-prep): stream prep output via PrepStarted/PrepLine/PrepFinished" +``` + +--- + +## Task 2: Worker — `ClearMyDay` hub method + +**Files:** +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` +- Test: a new/existing hub test under `tests/ClaudeDo.Worker.Tests/Hub/` (mirror an existing hub test that seeds a real SQLite db and constructs `WorkerHub`) + +- [ ] **Step 1: Write the failing test.** Seed three tasks: two with `IsMyDay=true` (one Idle, one Done), one with `IsMyDay=false`. Construct `WorkerHub` the way existing hub tests do (the same `null!` argument list, plus a recording `HubBroadcaster`/clients). Call `ClearMyDay()`; assert both MyDay rows are now `false`, the third is untouched, and the returned count is 2. + +```csharp +[Fact] +public async Task ClearMyDay_clears_all_isMyDay_tasks() +{ + // seed via the test's db helper ... + var hub = NewHub(/* ... */); + var cleared = await hub.ClearMyDay(); + + Assert.Equal(2, cleared); + await using var ctx = NewContext(); + Assert.False(await ctx.Tasks.AnyAsync(t => t.IsMyDay)); +} +``` + +- [ ] **Step 2: Run — expect FAIL.** + +- [ ] **Step 3: Add the method** to `WorkerHub` (use the same db-context acquisition the neighbouring hub methods use — e.g. `_dbFactory`/repository field name found in the file — and the existing `_broadcaster` field): + +```csharp +public async Task ClearMyDay() +{ + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var ids = await ctx.Tasks.Where(t => t.IsMyDay).Select(t => t.Id).ToListAsync(); + if (ids.Count == 0) return 0; + + await ctx.Tasks.Where(t => t.IsMyDay) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.IsMyDay, false)); + + foreach (var id in ids) + await _broadcaster.TaskUpdated(id); + + return ids.Count; +} +``` + +If `WorkerHub` does not already have an `IDbContextFactory` field, use whatever data-access dependency the other hub methods use (read the file). Do NOT add a new ctor param unless unavoidable (it would break hub-test fakes — if you must, update all `new WorkerHub(...)` call sites). + +- [ ] **Step 4: Run — expect PASS.** Build Worker. + +- [ ] **Step 5: Commit.** + +```bash +git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub +git commit -m "feat(daily-prep): add ClearMyDay hub method" +``` + +--- + +## Task 3: UI — WorkerClient prep events + ClearMyDayAsync + +**Files:** +- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` +- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs` +- Modify fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (FakeWorkerClient) + +- [ ] **Step 1: Declare on `IWorkerClient`** (near `TaskMessageEvent` / `RunDailyPrepNowAsync`): + +```csharp +event Action? PrepStartedEvent; +event Action? PrepLineEvent; +event Action? PrepFinishedEvent; +Task ClearMyDayAsync(); +``` + +- [ ] **Step 2: Implement in `WorkerClient`.** Add the events; register hub callbacks mirroring the `PrimeFired` registration (~line 170): + +```csharp +public event Action? PrepStartedEvent; +public event Action? PrepLineEvent; +public event Action? PrepFinishedEvent; + +// in the hub-wiring section: +_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke())); +_hub.On("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line))); +_hub.On("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok))); + +public Task ClearMyDayAsync() => _connection.InvokeAsync("ClearMyDay"); +``` + +(Use the exact connection field name and async-call style of neighbouring methods like `RunDailyPrepNowAsync` / `GenerateWeekReport`. `ClearMyDay` returns `int` on the hub; invoking it as a void `InvokeAsync("ClearMyDay")` is fine, or `InvokeAsync` if you want the count.) + +- [ ] **Step 3: Update the fakes.** Add the three events (as `public event …` auto-implemented) and `ClearMyDayAsync() => Task.CompletedTask` to both `StubWorkerClient` and `FakeWorkerClient`. For the ClearDay command test (Task 5), give `StubWorkerClient` a `ClearMyDayCalls` counter incremented in `ClearMyDayAsync`. + +- [ ] **Step 4: Build App + both test projects; fix any remaining fake gaps.** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +``` + +- [ ] **Step 5: Commit.** + +```bash +git add src/ClaudeDo.Ui/Services tests +git commit -m "feat(daily-prep): expose prep stream events and ClearMyDay on the UI worker client" +``` + +--- + +## Task 4: UI — Details island prep mode + live log + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` +- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` +- Test: `tests/ClaudeDo.Ui.Tests/...DetailsIslandViewModel...` (mirror existing Details VM tests; if none, add a small test file) + +- [ ] **Step 1: Write the failing test.** Construct `DetailsIslandViewModel` with a `StubWorkerClient` (mirror existing construction). Then: + +```csharp +[Fact] +public void PrepLine_event_appends_to_PrepLog() +{ + var stub = new StubWorkerClient(); + var vm = NewDetailsVm(stub); + + stub.RaisePrepLine("{\"type\":\"assistant\",\"text\":\"hi\"}"); // helper that invokes PrepLineEvent + Assert.NotEmpty(vm.PrepLog); +} + +[Fact] +public void ShowPrep_sets_prep_mode_and_clears_notes_mode() +{ + var vm = NewDetailsVm(new StubWorkerClient()); + vm.ShowPrep(); + Assert.True(vm.IsPrepMode); + Assert.False(vm.IsNotesMode); +} +``` + +Add `RaisePrepStarted/RaisePrepLine/RaisePrepFinished` helpers to `StubWorkerClient` that invoke the corresponding events. + +- [ ] **Step 2: Run — expect FAIL.** + +- [ ] **Step 3: Implement in `DetailsIslandViewModel`:** + - Add `[ObservableProperty] private bool _isPrepMode;` and `[ObservableProperty] private bool _isPrepRunning;`. + - Add `public ObservableCollection PrepLog { get; } = new();`. + - In the ctor, subscribe: `_worker.PrepStartedEvent += OnPrepStarted; _worker.PrepLineEvent += OnPrepLine; _worker.PrepFinishedEvent += OnPrepFinished;` (guard with the same `_worker is not null` pattern used for other events). + - Handlers: + +```csharp +private void OnPrepStarted() +{ + PrepLog.Clear(); + IsPrepRunning = true; +} + +private void OnPrepLine(string line) => AppendStdoutLine(PrepLog, line); + +private void OnPrepFinished(bool success) => IsPrepRunning = false; +``` + + - Factor the stdout-formatting currently inside `OnTaskMessage` into a reusable + `private void AppendStdoutLine(ObservableCollection target, string line)` + that runs the line through `StreamLineFormatter` and appends `LogLineViewModel`(s). + Have `OnTaskMessage`'s stdout branch call `AppendStdoutLine(Log, strippedLine)` so both + paths share one implementation. (Events arrive already on the UI thread via + `Dispatcher.UIThread.Post` in `WorkerClient`, so direct collection mutation is correct.) + - Add `public void ShowPrep()` mirroring `ShowNotes()`: call `Bind(null)`, set + `IsNotesMode = false`, `IsPrepMode = true`. + - In `ShowNotes()` add `IsPrepMode = false`. In `Bind(...)` reset both `IsNotesMode` and + `IsPrepMode` to false (find where `IsNotesMode` is reset; add `IsPrepMode` beside it). + +- [ ] **Step 4: Update `DetailsIslandView.axaml`.** + - Change the task-details panel visibility from `IsVisible="{Binding !IsNotesMode}"` to a + converter-free multi-condition. Avalonia lacks `&&` in bindings, so add a computed + property `public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode;` to the VM + (raise its change notification from the `OnIsNotesModeChanged`/`OnIsPrepModeChanged` + partial methods generated by `[ObservableProperty]`) and bind the task panel to + `IsVisible="{Binding IsTaskDetailVisible}"`. + - Add a third panel after the notes panel: + +```xml + + + + + + + + +``` + + The `ItemsControl` reuses the implicit `LogLineViewModel` `DataTemplate` that + `SessionTerminalView` relies on. If that template is defined locally inside + `SessionTerminalView.axaml` (not in a shared resource), either move it to a shared + `ResourceDictionary` (e.g. App resources) and reference it from both, or set the + `ItemsControl.ItemTemplate` to a copy of that template. Prefer sharing over copying. + Add `details.prepTitle` ("Daily prep" / "Tagesvorbereitung") to both locale json files. + +- [ ] **Step 5: Run UI tests — expect PASS; build App.** + +```bash +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +``` + +- [ ] **Step 6: Commit.** + +```bash +git add src/ClaudeDo.Ui src/ClaudeDo.Localization tests/ClaudeDo.Ui.Tests +git commit -m "feat(daily-prep): add live prep-output mode to the Details island" +``` + +--- + +## Task 5: UI — MyDay buttons + shell wiring + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` +- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` +- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` +- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json` +- Test: `tests/ClaudeDo.Worker.Tests/UiVm/...` or `tests/ClaudeDo.Ui.Tests/...` (TasksIslandViewModel) + +- [ ] **Step 1: Write the failing tests.** + +```csharp +[Fact] +public async Task ClearDayCommand_calls_worker() +{ + var stub = new StubWorkerClient(); + var vm = NewTasksVm(stub); + await vm.ClearDayCommand.ExecuteAsync(null); + Assert.Equal(1, stub.ClearMyDayCalls); +} + +[Fact] +public async Task PrepareDayCommand_raises_PrepRequested() +{ + var vm = NewTasksVm(new StubWorkerClient()); + var raised = false; + vm.PrepRequested += () => raised = true; + await vm.PrepareDayCommand.ExecuteAsync(null); + Assert.True(raised); +} +``` + +- [ ] **Step 2: Run — expect FAIL.** + +- [ ] **Step 3: Implement in `TasksIslandViewModel`:** + - Add `public event Action? PrepRequested;` next to `NotesRequested`. + - In `PrepareDayAsync` (the existing `[RelayCommand]`), raise `PrepRequested?.Invoke();` + in addition to the existing `RunDailyPrepNowAsync()` call. + - Add: + +```csharp +[RelayCommand] +private void ShowPrepLog() => PrepRequested?.Invoke(); + +[RelayCommand] +private async Task ClearDayAsync() +{ + if (_worker is null) return; + try { await _worker.ClearMyDayAsync(); } + catch { /* worker offline; broadcast will reconcile on return */ } +} +``` + +- [ ] **Step 4: Add the two buttons** to the MyDay header in `TasksIslandView.axaml`, + immediately after the existing "Prepare day" button (~line 84), copying its styling + (`DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch" + HorizontalContentAlignment="Left" Margin="16,0,16,8" IsVisible="{Binding IsMyDayList}"`): + +```xml +