# Weekly Report 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:** Generate a short, German, standup-focused weekly report from the user's Claude Code session history (excluding configurable private paths), enriched with per-day bullet notes authored in the My Day list.

**Architecture:** A new `Report/` area in the Worker scans `~/.claude/projects/*/*.jsonl`, distills prompts + closing summaries, and runs a one-shot `claude -p` call via the existing `ClaudeProcess` to produce markdown that is stored (keyed by date range) and reused. Daily notes live in SQLite and are authored via a pinned non-task "Notes" row in the My Day list that flips the Details island into a bullet-notes editor. The UI talks to the Worker over the existing SignalR hub.

**Tech Stack:** .NET 8, EF Core (SQLite), SignalR, Avalonia + CommunityToolkit.Mvvm, xUnit.

**Build/test note:** `.slnx` needs .NET 9. On .NET 8 build individual projects: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`, `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`, `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`. Tests: `dotnet test tests/ClaudeDo.Worker.Tests`.

**Spec:** `docs/superpowers/specs/2026-06-03-weekly-report-design.md`

---

## File Structure

**Data (`src/ClaudeDo.Data`)**
- Create `Models/DailyNoteEntity.cs`, `Models/WeekReportEntity.cs`
- Create `Configuration/DailyNoteEntityConfiguration.cs`, `Configuration/WeekReportEntityConfiguration.cs`
- Create `Repositories/DailyNoteRepository.cs`, `Repositories/WeekReportRepository.cs`
- Modify `Models/AppSettingsEntity.cs` (+ `ReportExcludedPaths`, `StandupWeekday`)
- Modify `ClaudeDoDbContext.cs` (+ two DbSets)
- Modify `Configuration/AppSettingsEntityConfiguration.cs` (map new columns)
- Generated: `Migrations/*_WeeklyReport.cs`

**Worker (`src/ClaudeDo.Worker/Report/`)**
- Create `Interfaces/IClaudeHistoryReader.cs`, `Interfaces/IWeekReportService.cs`
- Create `ReportModels.cs` (RepoActivity, DayActivity)
- Create `ClaudeHistoryReader.cs`, `WeekReportPromptBuilder.cs`, `WeekReportService.cs`
- Modify `Hub/WorkerHub.cs` (+ report & notes hub methods), `Program.cs` (DI), `Config/WorkerConfig.cs` (claude projects root override, optional)

**IPC (`src/ClaudeDo.Ui/Services`)**
- Create `ReportDtos.cs` (DailyNoteDto)
- Modify `WorkerClient.cs` (+ methods)

**UI (`src/ClaudeDo.Ui`)**
- Create `ViewModels/Modals/WeeklyReportModalViewModel.cs`, `Views/Modals/WeeklyReportModalView.axaml(.cs)`
- Create `ViewModels/Islands/NotesEditorViewModel.cs`, `Views/Islands/NotesEditorView.axaml(.cs)`
- Modify `ViewModels/Islands/ListsIslandViewModel.cs` (pinned Notes row), `ViewModels/Islands/DetailsIslandViewModel.cs` (notes mode), `Views/Islands/DetailsIslandView.axaml` (host notes editor)
- Modify `ViewModels/IslandsShellViewModel.cs` (open report modal, route Notes selection), `Views/MainWindow.axaml(.cs)` (menu item + modal host), `src/ClaudeDo.App/Program.cs` (DI)

---

## Phase 1 — Data layer

### Task 1: DailyNoteEntity + WeekReportEntity + AppSettings columns

**Files:**
- Create: `src/ClaudeDo.Data/Models/DailyNoteEntity.cs`
- Create: `src/ClaudeDo.Data/Models/WeekReportEntity.cs`
- Modify: `src/ClaudeDo.Data/Models/AppSettingsEntity.cs`

- [ ] **Step 1: Create `DailyNoteEntity.cs`**

```csharp
namespace ClaudeDo.Data.Models;

public sealed class DailyNoteEntity
{
    public string Id { get; init; } = Guid.NewGuid().ToString();
    public DateOnly Date { get; set; }
    public string Text { get; set; } = string.Empty;
    public int SortOrder { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
```

- [ ] **Step 2: Create `WeekReportEntity.cs`**

```csharp
namespace ClaudeDo.Data.Models;

public sealed class WeekReportEntity
{
    public string Id { get; init; } = Guid.NewGuid().ToString();
    public DateOnly StartDate { get; set; }
    public DateOnly EndDate { get; set; }
    public string Markdown { get; set; } = string.Empty;
    public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
}
```

- [ ] **Step 3: Add two columns to `AppSettingsEntity.cs`**

Add after the `RepoImportFolders` property (line 22):

```csharp
    // JSON array of path prefixes whose sessions are excluded from the weekly report.
    public string? ReportExcludedPaths { get; set; }
    // DayOfWeek the standup happens on; default Wednesday. Drives the report's default range.
    public int StandupWeekday { get; set; } = (int)DayOfWeek.Wednesday;
```

- [ ] **Step 4: Build the Data project**

Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: PASS (compiles).

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Data/Models/DailyNoteEntity.cs src/ClaudeDo.Data/Models/WeekReportEntity.cs src/ClaudeDo.Data/Models/AppSettingsEntity.cs
git commit -m "feat(data): add daily note + week report entities and report settings"
```

### Task 2: Entity configurations + DbContext DbSets

**Files:**
- Create: `src/ClaudeDo.Data/Configuration/DailyNoteEntityConfiguration.cs`
- Create: `src/ClaudeDo.Data/Configuration/WeekReportEntityConfiguration.cs`
- Modify: `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs`
- Modify: `src/ClaudeDo.Data/ClaudeDoDbContext.cs:14-21`

- [ ] **Step 1: Create `DailyNoteEntityConfiguration.cs`**

```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ClaudeDo.Data.Configuration;

public class DailyNoteEntityConfiguration : IEntityTypeConfiguration<DailyNoteEntity>
{
    public void Configure(EntityTypeBuilder<DailyNoteEntity> builder)
    {
        builder.ToTable("daily_notes");
        builder.HasKey(n => n.Id);
        builder.Property(n => n.Id).HasColumnName("id").ValueGeneratedNever();
        builder.Property(n => n.Date).HasColumnName("note_date").IsRequired();
        builder.Property(n => n.Text).HasColumnName("text").IsRequired();
        builder.Property(n => n.SortOrder).HasColumnName("sort_order").IsRequired();
        builder.Property(n => n.CreatedAt).HasColumnName("created_at").IsRequired();
        builder.HasIndex(n => n.Date);
    }
}
```

- [ ] **Step 2: Create `WeekReportEntityConfiguration.cs`**

```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ClaudeDo.Data.Configuration;

public class WeekReportEntityConfiguration : IEntityTypeConfiguration<WeekReportEntity>
{
    public void Configure(EntityTypeBuilder<WeekReportEntity> builder)
    {
        builder.ToTable("week_reports");
        builder.HasKey(r => r.Id);
        builder.Property(r => r.Id).HasColumnName("id").ValueGeneratedNever();
        builder.Property(r => r.StartDate).HasColumnName("start_date").IsRequired();
        builder.Property(r => r.EndDate).HasColumnName("end_date").IsRequired();
        builder.Property(r => r.Markdown).HasColumnName("markdown").IsRequired();
        builder.Property(r => r.GeneratedAt).HasColumnName("generated_at").IsRequired();
        builder.HasIndex(r => new { r.StartDate, r.EndDate }).IsUnique();
    }
}
```

- [ ] **Step 3: Map new AppSettings columns**

In `AppSettingsEntityConfiguration.cs`, inside `Configure`, add (match the existing column-naming style in that file):

```csharp
        builder.Property(s => s.ReportExcludedPaths).HasColumnName("report_excluded_paths");
        builder.Property(s => s.StandupWeekday).HasColumnName("standup_weekday")
            .IsRequired().HasDefaultValue((int)DayOfWeek.Wednesday);
```

- [ ] **Step 4: Add DbSets to `ClaudeDoDbContext.cs`**

After line 21 (`public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();`):

```csharp
    public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
    public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
```

- [ ] **Step 5: Build the Data project**

Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Data/Configuration/ src/ClaudeDo.Data/ClaudeDoDbContext.cs
git commit -m "feat(data): configure daily note + week report tables"
```

### Task 3: EF migration

**Files:**
- Generated: `src/ClaudeDo.Data/Migrations/*_WeeklyReport.cs` (+ Designer + snapshot update)

- [ ] **Step 1: Add the migration**

Run from repo root:
`dotnet ef migrations add WeeklyReport --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker`
Expected: creates `daily_notes`, `week_reports`, and adds `report_excluded_paths` + `standup_weekday` to `app_settings`.

- [ ] **Step 2: Inspect the generated migration**

Open the new `Migrations/*_WeeklyReport.cs`. Confirm `CreateTable("daily_notes")`, `CreateTable("week_reports")` with the unique index on `(start_date, end_date)`, and `AddColumn` for the two settings columns. No `DropTable`/`DropColumn` of existing schema.

- [ ] **Step 3: Build the Worker project (validates migration compiles)**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add src/ClaudeDo.Data/Migrations/
git commit -m "feat(data): migration for daily notes and week reports"
```

### Task 4: DailyNoteRepository (TDD)

**Files:**
- Create: `src/ClaudeDo.Data/Repositories/DailyNoteRepository.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/DailyNoteRepositoryTests.cs`

- [ ] **Step 1: Write the failing test**

```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;

namespace ClaudeDo.Worker.Tests.Repositories;

public class DailyNoteRepositoryTests : IDisposable
{
    private readonly DbFixture _db = new();
    public void Dispose() => _db.Dispose();

    [Fact]
    public async Task Add_AssignsIncrementingSortOrder_WithinDay()
    {
        var day = new DateOnly(2026, 6, 1);
        using (var ctx = _db.CreateContext())
        {
            var repo = new DailyNoteRepository(ctx);
            await repo.AddAsync(day, "first");
            await repo.AddAsync(day, "second");
        }
        using var read = _db.CreateContext();
        var notes = await new DailyNoteRepository(read).ListByDayAsync(day);
        Assert.Equal(new[] { "first", "second" }, notes.Select(n => n.Text));
        Assert.Equal(new[] { 0, 1 }, notes.Select(n => n.SortOrder));
    }

    [Fact]
    public async Task ListBetween_ReturnsOnlyInRange_OrderedByDateThenSort()
    {
        using (var ctx = _db.CreateContext())
        {
            var repo = new DailyNoteRepository(ctx);
            await repo.AddAsync(new DateOnly(2026, 5, 31), "before");
            await repo.AddAsync(new DateOnly(2026, 6, 1), "in-a");
            await repo.AddAsync(new DateOnly(2026, 6, 2), "in-b");
            await repo.AddAsync(new DateOnly(2026, 6, 5), "after");
        }
        using var read = _db.CreateContext();
        var notes = await new DailyNoteRepository(read)
            .ListBetweenAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 2));
        Assert.Equal(new[] { "in-a", "in-b" }, notes.Select(n => n.Text));
    }

    [Fact]
    public async Task Update_And_Delete_Work()
    {
        var day = new DateOnly(2026, 6, 1);
        string id;
        using (var ctx = _db.CreateContext())
        {
            var n = await new DailyNoteRepository(ctx).AddAsync(day, "orig");
            id = n.Id;
        }
        using (var ctx = _db.CreateContext())
            await new DailyNoteRepository(ctx).UpdateAsync(id, "edited");
        using (var ctx = _db.CreateContext())
        {
            var notes = await new DailyNoteRepository(ctx).ListByDayAsync(day);
            Assert.Equal("edited", notes.Single().Text);
        }
        using (var ctx = _db.CreateContext())
            await new DailyNoteRepository(ctx).DeleteAsync(id);
        using var read = _db.CreateContext();
        Assert.Empty(await new DailyNoteRepository(read).ListByDayAsync(day));
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter DailyNoteRepositoryTests`
Expected: FAIL — `DailyNoteRepository` does not exist (compile error).

- [ ] **Step 3: Implement `DailyNoteRepository.cs`**

```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;

namespace ClaudeDo.Data.Repositories;

public sealed class DailyNoteRepository
{
    private readonly ClaudeDoDbContext _context;

    public DailyNoteRepository(ClaudeDoDbContext context) => _context = context;

    public async Task<IReadOnlyList<DailyNoteEntity>> ListByDayAsync(DateOnly day, CancellationToken ct = default) =>
        await _context.DailyNotes.AsNoTracking()
            .Where(n => n.Date == day)
            .OrderBy(n => n.SortOrder)
            .ToListAsync(ct);

    public async Task<IReadOnlyList<DailyNoteEntity>> ListBetweenAsync(
        DateOnly start, DateOnly end, CancellationToken ct = default) =>
        await _context.DailyNotes.AsNoTracking()
            .Where(n => n.Date >= start && n.Date <= end)
            .OrderBy(n => n.Date).ThenBy(n => n.SortOrder)
            .ToListAsync(ct);

    public async Task<DailyNoteEntity> AddAsync(DateOnly day, string text, CancellationToken ct = default)
    {
        var nextOrder = await _context.DailyNotes
            .Where(n => n.Date == day)
            .Select(n => (int?)n.SortOrder)
            .MaxAsync(ct) ?? -1;

        var note = new DailyNoteEntity
        {
            Date = day,
            Text = text,
            SortOrder = nextOrder + 1,
            CreatedAt = DateTime.UtcNow,
        };
        _context.DailyNotes.Add(note);
        await _context.SaveChangesAsync(ct);
        return note;
    }

    public async Task UpdateAsync(string id, string text, CancellationToken ct = default)
    {
        var row = await _context.DailyNotes.FirstOrDefaultAsync(n => n.Id == id, ct);
        if (row is null) return;
        row.Text = text;
        await _context.SaveChangesAsync(ct);
    }

    public async Task DeleteAsync(string id, CancellationToken ct = default)
    {
        var row = await _context.DailyNotes.FirstOrDefaultAsync(n => n.Id == id, ct);
        if (row is null) return;
        _context.DailyNotes.Remove(row);
        await _context.SaveChangesAsync(ct);
    }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter DailyNoteRepositoryTests`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Data/Repositories/DailyNoteRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/DailyNoteRepositoryTests.cs
git commit -m "feat(data): add DailyNoteRepository with tests"
```

### Task 5: WeekReportRepository (TDD)

**Files:**
- Create: `src/ClaudeDo.Data/Repositories/WeekReportRepository.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/WeekReportRepositoryTests.cs`

- [ ] **Step 1: Write the failing test**

```csharp
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;

namespace ClaudeDo.Worker.Tests.Repositories;

public class WeekReportRepositoryTests : IDisposable
{
    private readonly DbFixture _db = new();
    public void Dispose() => _db.Dispose();

    private static readonly DateOnly Start = new(2026, 5, 28);
    private static readonly DateOnly End = new(2026, 6, 3);

    [Fact]
    public async Task Upsert_Insert_Then_GetByRange_RoundTrips()
    {
        using (var ctx = _db.CreateContext())
            await new WeekReportRepository(ctx).UpsertAsync(Start, End, "# Report");
        using var read = _db.CreateContext();
        var row = await new WeekReportRepository(read).GetByRangeAsync(Start, End);
        Assert.NotNull(row);
        Assert.Equal("# Report", row!.Markdown);
    }

    [Fact]
    public async Task Upsert_Existing_Overwrites_Markdown()
    {
        using (var ctx = _db.CreateContext())
            await new WeekReportRepository(ctx).UpsertAsync(Start, End, "old");
        using (var ctx = _db.CreateContext())
            await new WeekReportRepository(ctx).UpsertAsync(Start, End, "new");
        using var read = _db.CreateContext();
        var row = await new WeekReportRepository(read).GetByRangeAsync(Start, End);
        Assert.Equal("new", row!.Markdown);
    }

    [Fact]
    public async Task GetByRange_MissingRange_ReturnsNull()
    {
        using var read = _db.CreateContext();
        Assert.Null(await new WeekReportRepository(read).GetByRangeAsync(Start, End));
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportRepositoryTests`
Expected: FAIL — `WeekReportRepository` does not exist.

- [ ] **Step 3: Implement `WeekReportRepository.cs`**

```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;

namespace ClaudeDo.Data.Repositories;

public sealed class WeekReportRepository
{
    private readonly ClaudeDoDbContext _context;

    public WeekReportRepository(ClaudeDoDbContext context) => _context = context;

    public async Task<WeekReportEntity?> GetByRangeAsync(
        DateOnly start, DateOnly end, CancellationToken ct = default) =>
        await _context.WeekReports.AsNoTracking()
            .FirstOrDefaultAsync(r => r.StartDate == start && r.EndDate == end, ct);

    public async Task UpsertAsync(DateOnly start, DateOnly end, string markdown, CancellationToken ct = default)
    {
        var row = await _context.WeekReports
            .FirstOrDefaultAsync(r => r.StartDate == start && r.EndDate == end, ct);
        if (row is null)
        {
            _context.WeekReports.Add(new WeekReportEntity
            {
                StartDate = start,
                EndDate = end,
                Markdown = markdown,
                GeneratedAt = DateTime.UtcNow,
            });
        }
        else
        {
            row.Markdown = markdown;
            row.GeneratedAt = DateTime.UtcNow;
        }
        await _context.SaveChangesAsync(ct);
    }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportRepositoryTests`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Data/Repositories/WeekReportRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/WeekReportRepositoryTests.cs
git commit -m "feat(data): add WeekReportRepository with tests"
```

---

## Phase 2 — Worker report pipeline

### Task 6: Report models + reader interface

**Files:**
- Create: `src/ClaudeDo.Worker/Report/ReportModels.cs`
- Create: `src/ClaudeDo.Worker/Report/Interfaces/IClaudeHistoryReader.cs`

- [ ] **Step 1: Create `ReportModels.cs`**

```csharp
namespace ClaudeDo.Worker.Report;

public sealed class DayActivity
{
    public DateOnly Date { get; init; }
    public List<string> Prompts { get; } = new();
    public List<string> Summaries { get; } = new();
}

public sealed class RepoActivity
{
    public required string RepoPath { get; init; }
    public List<DayActivity> Days { get; } = new();
}
```

- [ ] **Step 2: Create `Interfaces/IClaudeHistoryReader.cs`**

```csharp
namespace ClaudeDo.Worker.Report.Interfaces;

public interface IClaudeHistoryReader
{
    Task<IReadOnlyList<RepoActivity>> ReadAsync(
        DateOnly start,
        DateOnly end,
        IReadOnlyList<string> excludedPrefixes,
        CancellationToken ct = default);
}
```

- [ ] **Step 3: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add src/ClaudeDo.Worker/Report/ReportModels.cs src/ClaudeDo.Worker/Report/Interfaces/IClaudeHistoryReader.cs
git commit -m "feat(worker): report activity models and reader interface"
```

### Task 7: ClaudeHistoryReader (TDD)

Parses `<root>/*/*.jsonl`. Keeps user prompt text and the **last** assistant text per session file, grouped by `cwd` → date, filtered to `[start, end]` (inclusive, by the message timestamp's local date), dropping sessions/messages whose `cwd` starts with any excluded prefix. Malformed lines are skipped. Prompts containing `<system-reminder>` or empty text are skipped.

**Files:**
- Create: `src/ClaudeDo.Worker/Report/ClaudeHistoryReader.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Report/ClaudeHistoryReaderTests.cs`

- [ ] **Step 1: Write the failing test**

```csharp
using ClaudeDo.Worker.Report;

namespace ClaudeDo.Worker.Tests.Report;

public class ClaudeHistoryReaderTests : IDisposable
{
    private readonly string _root;
    public ClaudeHistoryReaderTests()
    {
        _root = Path.Combine(Path.GetTempPath(), $"cdh_{Guid.NewGuid():N}");
        Directory.CreateDirectory(_root);
    }
    public void Dispose() { try { Directory.Delete(_root, true); } catch { } }

    private void WriteSession(string projectDir, string file, params string[] lines)
    {
        var dir = Path.Combine(_root, projectDir);
        Directory.CreateDirectory(dir);
        File.WriteAllLines(Path.Combine(dir, file), lines);
    }

    private static string UserLine(string cwd, string ts, string text) =>
        $$"""{"type":"user","cwd":{{Json(cwd)}},"timestamp":"{{ts}}","message":{"role":"user","content":[{"type":"text","text":{{Json(text)}}}]}}""";

    private static string AssistantLine(string cwd, string ts, string text) =>
        $$"""{"type":"assistant","cwd":{{Json(cwd)}},"timestamp":"{{ts}}","message":{"role":"assistant","content":[{"type":"text","text":{{Json(text)}}}]}}""";

    private static string Json(string s) => System.Text.Json.JsonSerializer.Serialize(s);

    [Fact]
    public async Task Extracts_Prompts_And_Last_Assistant_Summary_GroupedByRepoAndDay()
    {
        WriteSession("proj", "s1.jsonl",
            UserLine(@"C:\Dev\Repos\App", "2026-06-01T08:00:00Z", "Add login"),
            AssistantLine(@"C:\Dev\Repos\App", "2026-06-01T08:05:00Z", "first summary"),
            AssistantLine(@"C:\Dev\Repos\App", "2026-06-01T08:30:00Z", "final summary"));

        var reader = new ClaudeHistoryReader(_root);
        var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3),
            Array.Empty<string>());

        var repo = Assert.Single(result);
        Assert.Equal(@"C:\Dev\Repos\App", repo.RepoPath);
        var day = Assert.Single(repo.Days);
        Assert.Equal(new[] { "Add login" }, day.Prompts);
        Assert.Equal(new[] { "final summary" }, day.Summaries);
    }

    [Fact]
    public async Task Drops_Sessions_Under_Excluded_Prefix_CaseInsensitive()
    {
        WriteSession("priv", "s.jsonl",
            UserLine(@"C:\Private\Secret", "2026-06-01T08:00:00Z", "private work"));

        var reader = new ClaudeHistoryReader(_root);
        var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3),
            new[] { @"c:\private" });

        Assert.Empty(result);
    }

    [Fact]
    public async Task Filters_By_Date_Window_And_Skips_Noise_And_Malformed()
    {
        WriteSession("proj", "s.jsonl",
            "this is not json",
            UserLine(@"C:\Dev\App", "2026-05-01T08:00:00Z", "too old"),
            UserLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "in range"),
            UserLine(@"C:\Dev\App", "2026-06-02T09:00:00Z", "noise <system-reminder> blah"));

        var reader = new ClaudeHistoryReader(_root);
        var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3),
            Array.Empty<string>());

        var day = Assert.Single(Assert.Single(result).Days);
        Assert.Equal(new[] { "in range" }, day.Prompts);
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ClaudeHistoryReaderTests`
Expected: FAIL — `ClaudeHistoryReader` does not exist.

- [ ] **Step 3: Implement `ClaudeHistoryReader.cs`**

```csharp
using System.Text.Json;
using ClaudeDo.Worker.Report.Interfaces;

namespace ClaudeDo.Worker.Report;

public sealed class ClaudeHistoryReader : IClaudeHistoryReader
{
    private readonly string _projectsRoot;

    public ClaudeHistoryReader(string projectsRoot) => _projectsRoot = projectsRoot;

    public Task<IReadOnlyList<RepoActivity>> ReadAsync(
        DateOnly start, DateOnly end, IReadOnlyList<string> excludedPrefixes, CancellationToken ct = default)
    {
        // (repoPath, date) -> day bucket
        var buckets = new Dictionary<(string Repo, DateOnly Date), DayActivity>();
        var normalizedExcludes = excludedPrefixes
            .Select(NormalizePath).Where(p => p.Length > 0).ToArray();

        if (Directory.Exists(_projectsRoot))
        {
            foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories))
            {
                ct.ThrowIfCancellationRequested();
                ReadFile(file, start, end, normalizedExcludes, buckets);
            }
        }

        var repos = buckets
            .GroupBy(kv => kv.Key.Repo)
            .Select(g =>
            {
                var ra = new RepoActivity { RepoPath = g.Key };
                foreach (var day in g.OrderBy(kv => kv.Key.Date).Select(kv => kv.Value))
                    ra.Days.Add(day);
                return ra;
            })
            .OrderBy(r => r.RepoPath)
            .ToList();

        return Task.FromResult<IReadOnlyList<RepoActivity>>(repos);
    }

    private static void ReadFile(
        string file, DateOnly start, DateOnly end, string[] excludes,
        Dictionary<(string, DateOnly), DayActivity> buckets)
    {
        // Track the last assistant text in this session file so we keep only the closing summary.
        string? lastAssistantText = null;
        string? lastAssistantRepo = null;
        DateOnly lastAssistantDate = default;

        foreach (var line in File.ReadLines(file))
        {
            if (string.IsNullOrWhiteSpace(line)) continue;
            JsonDocument doc;
            try { doc = JsonDocument.Parse(line); }
            catch (JsonException) { continue; }
            using (doc)
            {
                var root = doc.RootElement;
                if (root.ValueKind != JsonValueKind.Object) continue;
                if (!root.TryGetProperty("type", out var typeEl)) continue;
                var type = typeEl.GetString();
                if (type is not ("user" or "assistant")) continue;
                if (!root.TryGetProperty("cwd", out var cwdEl) || cwdEl.ValueKind != JsonValueKind.String) continue;
                var cwd = cwdEl.GetString()!;
                if (IsExcluded(cwd, excludes)) continue;
                if (!root.TryGetProperty("timestamp", out var tsEl) ||
                    !DateTimeOffset.TryParse(tsEl.GetString(), out var ts)) continue;
                var date = DateOnly.FromDateTime(ts.LocalDateTime);
                if (date < start || date > end) continue;

                var text = ExtractText(root);
                if (string.IsNullOrWhiteSpace(text)) continue;

                if (type == "user")
                {
                    if (text.Contains("<system-reminder>", StringComparison.OrdinalIgnoreCase)) continue;
                    Bucket(buckets, cwd, date).Prompts.Add(text.Trim());
                }
                else // assistant — remember only the last
                {
                    lastAssistantText = text.Trim();
                    lastAssistantRepo = cwd;
                    lastAssistantDate = date;
                }
            }
        }

        if (lastAssistantText is not null && lastAssistantRepo is not null)
            Bucket(buckets, lastAssistantRepo, lastAssistantDate).Summaries.Add(lastAssistantText);
    }

    private static DayActivity Bucket(
        Dictionary<(string, DateOnly), DayActivity> buckets, string repo, DateOnly date)
    {
        var key = (repo, date);
        if (!buckets.TryGetValue(key, out var day))
        {
            day = new DayActivity { Date = date };
            buckets[key] = day;
        }
        return day;
    }

    private static string ExtractText(JsonElement root)
    {
        if (!root.TryGetProperty("message", out var msg) ||
            !msg.TryGetProperty("content", out var content)) return "";
        if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? "";
        if (content.ValueKind != JsonValueKind.Array) return "";

        var parts = new List<string>();
        foreach (var item in content.EnumerateArray())
        {
            if (item.ValueKind != JsonValueKind.Object) continue;
            if (item.TryGetProperty("type", out var t) && t.GetString() == "text" &&
                item.TryGetProperty("text", out var txt) && txt.ValueKind == JsonValueKind.String)
                parts.Add(txt.GetString() ?? "");
        }
        return string.Join("\n", parts);
    }

    private static bool IsExcluded(string cwd, string[] excludes)
    {
        var norm = NormalizePath(cwd);
        return excludes.Any(p => norm.StartsWith(p, StringComparison.Ordinal));
    }

    private static string NormalizePath(string p) =>
        (p ?? "").Replace('/', '\\').TrimEnd('\\').ToLowerInvariant();
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ClaudeHistoryReaderTests`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Worker/Report/ClaudeHistoryReader.cs tests/ClaudeDo.Worker.Tests/Report/ClaudeHistoryReaderTests.cs
git commit -m "feat(worker): ClaudeHistoryReader distills session logs"
```

### Task 8: WeekReportPromptBuilder (TDD)

Pivots repo→day activity into **day-major** order and renders the prompt from the template in the spec. Pure function — no I/O.

**Files:**
- Create: `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs`

- [ ] **Step 1: Write the failing test**

```csharp
using ClaudeDo.Worker.Report;

namespace ClaudeDo.Worker.Tests.Report;

public class WeekReportPromptBuilderTests
{
    [Fact]
    public void Build_IsDayMajor_AndIncludesNotesAndInstructions()
    {
        var repoA = new RepoActivity { RepoPath = @"C:\Dev\App" };
        var d1 = new DayActivity { Date = new DateOnly(2026, 6, 1) };
        d1.Prompts.Add("Add login");
        d1.Summaries.Add("Implemented login");
        repoA.Days.Add(d1);
        var d2 = new DayActivity { Date = new DateOnly(2026, 6, 2) };
        d2.Prompts.Add("Fix bug");
        repoA.Days.Add(d2);

        var notes = new Dictionary<DateOnly, List<string>>
        {
            [new DateOnly(2026, 6, 1)] = new() { "Standup um 9" },
        };

        var prompt = WeekReportPromptBuilder.Build(
            new DateOnly(2026, 5, 28), new DateOnly(2026, 6, 3),
            new[] { repoA }, notes);

        Assert.Contains("Write the ENTIRE report in German", prompt);
        // Day-major: 2026-06-01 section appears before 2026-06-02 section
        var idxJun1 = prompt.IndexOf("2026-06-01", StringComparison.Ordinal);
        var idxJun2 = prompt.IndexOf("2026-06-02", StringComparison.Ordinal);
        Assert.True(idxJun1 >= 0 && idxJun2 > idxJun1);
        Assert.Contains("Add login", prompt);
        Assert.Contains("Implemented login", prompt);
        Assert.Contains("Standup um 9", prompt);
        Assert.Contains(@"C:\Dev\App", prompt);
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportPromptBuilderTests`
Expected: FAIL — `WeekReportPromptBuilder` does not exist.

- [ ] **Step 3: Implement `WeekReportPromptBuilder.cs`**

```csharp
using System.Globalization;
using System.Text;

namespace ClaudeDo.Worker.Report;

public static class WeekReportPromptBuilder
{
    private const string Instructions = """
        You are generating a concise weekly standup report for a software developer.
        Summarize what they accomplished between {0} and {1}.

        Rules:
        - Write the ENTIRE report in German.
        - Group by day. One "## {{Wochentag}}, {{dd.MM.yyyy}}" section per day that has
          activity (German weekday names). Omit days with no activity entirely.
        - Within each day: 3-5 first-person, past-tense bullets ("- Habe X umgesetzt",
          "- Y behoben"). Merge related small work into one bullet.
        - Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
        - Blend the developer's own notes and the derived activity into ONE deduplicated
          bullet list per day. The developer's notes are authoritative - never omit or
          contradict their substance.
        - Name the project/repo when it adds clarity.
        - Output ONLY the dated sections. No preamble, no intro, no closing remarks.
        """;

    public static string Build(
        DateOnly start, DateOnly end,
        IReadOnlyList<RepoActivity> activity,
        IReadOnlyDictionary<DateOnly, List<string>> notesByDay)
    {
        var sb = new StringBuilder();
        sb.AppendLine(string.Format(CultureInfo.InvariantCulture, Instructions,
            start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
            end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)));
        sb.AppendLine();

        // Pivot repo->day into day-major.
        var days = new SortedDictionary<DateOnly, List<(string Repo, DayActivity Day)>>();
        foreach (var repo in activity)
            foreach (var day in repo.Days)
            {
                if (!days.TryGetValue(day.Date, out var list))
                    days[day.Date] = list = new();
                list.Add((repo.RepoPath, day));
            }
        foreach (var d in notesByDay.Keys)
            if (!days.ContainsKey(d)) days[d] = new();

        sb.AppendLine("== Activity (from session history) ==");
        foreach (var (date, repos) in days)
        {
            sb.AppendLine($"### {date:yyyy-MM-dd}");
            foreach (var (repoPath, day) in repos)
            {
                sb.AppendLine($"Repo: {repoPath}");
                foreach (var p in day.Prompts) sb.AppendLine($"  Prompt: {p}");
                foreach (var s in day.Summaries) sb.AppendLine($"  Summary: {s}");
            }
            sb.AppendLine();
        }

        sb.AppendLine("== Developer notes ==");
        foreach (var (date, _) in days)
        {
            if (!notesByDay.TryGetValue(date, out var notes) || notes.Count == 0) continue;
            sb.AppendLine($"### {date:yyyy-MM-dd}");
            foreach (var n in notes) sb.AppendLine($"  - {n}");
        }

        return sb.ToString();
    }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportPromptBuilderTests`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs
git commit -m "feat(worker): week report prompt builder (day-major pivot)"
```

### Task 9: WeekReportService (TDD)

Orchestrates: read settings → distill activity → load notes → empty-window short-circuit → build prompt → one-shot `claude -p` → store + return. Throws `InvalidOperationException` on a failed Claude run (the hub maps it to a client error) and does not store.

**Files:**
- Create: `src/ClaudeDo.Worker/Report/Interfaces/IWeekReportService.cs`
- Create: `src/ClaudeDo.Worker/Report/WeekReportService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs`

- [ ] **Step 1: Create `Interfaces/IWeekReportService.cs`**

```csharp
namespace ClaudeDo.Worker.Report.Interfaces;

public interface IWeekReportService
{
    Task<string?> GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default);
    Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default);
}
```

- [ ] **Step 2: Write the failing test**

```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;

namespace ClaudeDo.Worker.Tests.Report;

public class WeekReportServiceTests : IDisposable
{
    private readonly DbFixture _db = new();
    public void Dispose() => _db.Dispose();

    private static readonly DateOnly Start = new(2026, 5, 28);
    private static readonly DateOnly End = new(2026, 6, 3);

    private sealed class FakeReader : IClaudeHistoryReader
    {
        public IReadOnlyList<RepoActivity> Result = Array.Empty<RepoActivity>();
        public Task<IReadOnlyList<RepoActivity>> ReadAsync(
            DateOnly s, DateOnly e, IReadOnlyList<string> ex, CancellationToken ct) => Task.FromResult(Result);
    }

    private sealed class FakeClaude : IClaudeProcess
    {
        public int Calls;
        public RunResult Next = new() { ExitCode = 0, ResultMarkdown = "## Bericht" };
        public Task<RunResult> RunAsync(string args, string prompt, string wd, Func<string, Task> onLine, CancellationToken ct)
        { Calls++; return Task.FromResult(Next); }
    }

    private WeekReportService Make(FakeReader reader, FakeClaude claude) =>
        new(reader, _db.CreateFactory(), claude, NullLogger<WeekReportService>.Instance);

    [Fact]
    public async Task EmptyWindow_ProducesNoActivityReport_WithoutCallingClaude()
    {
        var claude = new FakeClaude();
        var svc = Make(new FakeReader(), claude);

        var md = await svc.GenerateAsync(Start, End);

        Assert.Equal(0, claude.Calls);
        Assert.Contains("Keine Aktivität", md);
        using var ctx = _db.CreateContext();
        Assert.NotNull(await new WeekReportRepository(ctx).GetByRangeAsync(Start, End));
    }

    [Fact]
    public async Task SuccessPath_StoresAndReturnsClaudeMarkdown()
    {
        var repo = new RepoActivity { RepoPath = @"C:\Dev\App" };
        var day = new DayActivity { Date = new DateOnly(2026, 6, 1) };
        day.Prompts.Add("Add login");
        repo.Days.Add(day);
        var claude = new FakeClaude { Next = new RunResult { ExitCode = 0, ResultMarkdown = "## Bericht\n- Habe Login umgesetzt" } };
        var svc = Make(new FakeReader { Result = new[] { repo } }, claude);

        var md = await svc.GenerateAsync(Start, End);

        Assert.Equal(1, claude.Calls);
        Assert.Contains("Habe Login umgesetzt", md);
        Assert.Equal(md, await svc.GetStoredAsync(Start, End));
    }

    [Fact]
    public async Task ClaudeFailure_Throws_AndDoesNotStore()
    {
        var repo = new RepoActivity { RepoPath = @"C:\Dev\App" };
        var day = new DayActivity { Date = new DateOnly(2026, 6, 1) };
        day.Prompts.Add("x");
        repo.Days.Add(day);
        var claude = new FakeClaude { Next = new RunResult { ExitCode = 1, ErrorMarkdown = "boom" } };
        var svc = Make(new FakeReader { Result = new[] { repo } }, claude);

        await Assert.ThrowsAsync<InvalidOperationException>(() => svc.GenerateAsync(Start, End));
        Assert.Null(await svc.GetStoredAsync(Start, End));
    }
}
```

- [ ] **Step 3: Run the test to verify it fails**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportServiceTests`
Expected: FAIL — `WeekReportService` does not exist.

- [ ] **Step 4: Implement `WeekReportService.cs`**

```csharp
using System.Text.Json;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace ClaudeDo.Worker.Report;

public sealed class WeekReportService : IWeekReportService
{
    private static readonly string[] DefaultExcludes = { @"C:\Private" };
    private const string NoActivity = "_Keine Aktivität in diesem Zeitraum._";

    private readonly IClaudeHistoryReader _reader;
    private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
    private readonly IClaudeProcess _claude;
    private readonly ILogger<WeekReportService> _logger;

    public WeekReportService(
        IClaudeHistoryReader reader,
        IDbContextFactory<ClaudeDoDbContext> dbFactory,
        IClaudeProcess claude,
        ILogger<WeekReportService> logger)
    {
        _reader = reader;
        _dbFactory = dbFactory;
        _claude = claude;
        _logger = logger;
    }

    public async Task<string?> GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
    {
        await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
        var row = await new WeekReportRepository(ctx).GetByRangeAsync(start, end, ct);
        return row?.Markdown;
    }

    public async Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
    {
        string[] excludes;
        string model;
        IReadOnlyList<Data.Models.DailyNoteEntity> noteRows;

        await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
        {
            var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
            excludes = ParseExcludes(settings.ReportExcludedPaths);
            model = string.IsNullOrWhiteSpace(settings.DefaultModel) ? "sonnet" : settings.DefaultModel;
            noteRows = await new DailyNoteRepository(ctx).ListBetweenAsync(start, end, ct);
        }

        var activity = await _reader.ReadAsync(start, end, excludes, ct);
        var notesByDay = noteRows
            .GroupBy(n => n.Date)
            .ToDictionary(g => g.Key, g => g.Select(n => n.Text).ToList());

        string markdown;
        var hasActivity = activity.Any(r => r.Days.Any(d => d.Prompts.Count > 0 || d.Summaries.Count > 0));
        if (!hasActivity && notesByDay.Count == 0)
        {
            markdown = NoActivity;
        }
        else
        {
            var prompt = WeekReportPromptBuilder.Build(start, end, activity, notesByDay);
            var args = $"-p --output-format stream-json --verbose --permission-mode auto --model {model}";
            var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
            if (!result.IsSuccess)
                throw new InvalidOperationException(result.ErrorMarkdown ?? "Claude konnte den Bericht nicht erzeugen.");
            markdown = result.ResultMarkdown!;
        }

        await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
            await new WeekReportRepository(ctx).UpsertAsync(start, end, markdown, ct);

        return markdown;
    }

    private static string[] ParseExcludes(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) return DefaultExcludes;
        try
        {
            var list = JsonSerializer.Deserialize<List<string>>(json);
            return list is { Count: > 0 } ? list.ToArray() : DefaultExcludes;
        }
        catch (JsonException) { return DefaultExcludes; }
    }
}
```

- [ ] **Step 5: Run the test to verify it passes**

Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportServiceTests`
Expected: PASS (3 tests).

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Worker/Report/Interfaces/IWeekReportService.cs src/ClaudeDo.Worker/Report/WeekReportService.cs tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs
git commit -m "feat(worker): WeekReportService orchestrates generate + store"
```

---

## Phase 3 — IPC wiring (DI, hub, client)

> These are wiring tasks. They are verified by build + the manual smoke test in Phase 7 (hub method tests need live-hub infrastructure that is out of scope here).

### Task 10: Register report services in DI

**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs` (after line 52, near the other singletons)

- [ ] **Step 1: Add registrations**

Add after the `TaskRunner`/Prime singletons block (e.g. after line 63), and add the `using` if missing (`using ClaudeDo.Worker.Report;` / `using ClaudeDo.Worker.Report.Interfaces;`):

```csharp
builder.Services.AddSingleton<IClaudeHistoryReader>(_ =>
    new ClaudeHistoryReader(Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects")));
builder.Services.AddSingleton<IWeekReportService, WeekReportService>();
```

- [ ] **Step 2: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): register report reader and service in DI"
```

### Task 11: Hub methods + DailyNoteDto (Worker side)

**Files:**
- Create: `src/ClaudeDo.Worker/Report/DailyNoteDto.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (constructor + new methods)

- [ ] **Step 1: Create `DailyNoteDto.cs`**

```csharp
namespace ClaudeDo.Worker.Report;

public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder);
```

- [ ] **Step 2: Inject `IWeekReportService` into `WorkerHub`**

In `WorkerHub.cs`: add field `private readonly IWeekReportService _report;`, add constructor parameter `IWeekReportService report,` (e.g. after `ITaskStateService state`), and assign `_report = report;`. Add `using ClaudeDo.Worker.Report;` and `using ClaudeDo.Worker.Report.Interfaces;` and `using ClaudeDo.Data.Repositories;` (if not present) and `using System.Globalization;`.

- [ ] **Step 3: Add the hub methods (before the closing brace of `WorkerHub`)**

```csharp
    private static DateOnly Day(string iso) => DateOnly.ParseExact(iso, "yyyy-MM-dd", CultureInfo.InvariantCulture);

    public Task<string?> GetWeekReport(string startIso, string endIso) =>
        _report.GetStoredAsync(Day(startIso), Day(endIso));

    public Task<string> GenerateWeekReport(string startIso, string endIso) =>
        HubGuard(() => _report.GenerateAsync(Day(startIso), Day(endIso)), "report generation failed");

    public async Task<List<DailyNoteDto>> GetDailyNotes(string dayIso)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso));
        return notes.Select(n => new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder)).ToList();
    }

    public async Task<DailyNoteDto> AddDailyNote(string dayIso, string text)
    {
        using var ctx = _dbFactory.CreateDbContext();
        var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text);
        return new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder);
    }

    public async Task UpdateDailyNote(string id, string text)
    {
        using var ctx = _dbFactory.CreateDbContext();
        await new DailyNoteRepository(ctx).UpdateAsync(id, text);
    }

    public async Task DeleteDailyNote(string id)
    {
        using var ctx = _dbFactory.CreateDbContext();
        await new DailyNoteRepository(ctx).DeleteAsync(id);
    }
```

- [ ] **Step 4: Build**

Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Worker/Report/DailyNoteDto.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): hub methods for week report and daily notes"
```

### Task 12: WorkerClient methods + UI DailyNoteDto

**Files:**
- Create: `src/ClaudeDo.Ui/Services/ReportDtos.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`

- [ ] **Step 1: Create `ReportDtos.cs`**

```csharp
namespace ClaudeDo.Ui.Services;

public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder);
```

- [ ] **Step 2: Add methods to `WorkerClient.cs`**

Add near the other invoke methods (e.g. after the Prime methods around line 325). `_hub` and `TryInvokeAsync<T>` already exist:

```csharp
    private static string IsoDay(DateOnly d) => d.ToString("yyyy-MM-dd");

    public Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end)
        => TryInvokeAsync<string>("GetWeekReport", IsoDay(start), IsoDay(end));

    public Task<string?> GenerateWeekReportAsync(DateOnly start, DateOnly end)
        => TryInvokeAsync<string>("GenerateWeekReport", IsoDay(start), IsoDay(end));

    public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
        => await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();

    public Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
        => TryInvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text);

    public async Task UpdateDailyNoteAsync(string id, string text)
        => await _hub.InvokeAsync("UpdateDailyNote", id, text);

    public async Task DeleteDailyNoteAsync(string id)
        => await _hub.InvokeAsync("DeleteDailyNote", id);
```

Note: `GenerateWeekReportAsync` uses `TryInvokeAsync` so a hub-side failure (mapped to `HubException`) surfaces as a thrown exception the modal can catch — confirm `TryInvokeAsync` rethrows `HubException` rather than swallowing it; if it swallows, call `_hub.InvokeAsync<string>("GenerateWeekReport", ...)` directly instead.

- [ ] **Step 2b: Declare the same six methods on `IWorkerClient`**

Add to `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`:

```csharp
    Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
    Task<string?> GenerateWeekReportAsync(DateOnly start, DateOnly end);
    Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
    Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
    Task UpdateDailyNoteAsync(string id, string text);
    Task DeleteDailyNoteAsync(string id);
```

- [ ] **Step 3: Build**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add src/ClaudeDo.Ui/Services/ReportDtos.cs src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): WorkerClient methods for week report and daily notes"
```

---

## Phase 4 — Settings (excluded paths + standup weekday)

`AppSettingsDto` is a positional record constructed in two call sites (hub `UpdateAppSettings`, UI `SettingsModalViewModel.Save`) and read in two (hub `GetAppSettings`, UI `LoadAsync`). All four must change together. The two new fields go at the END of the record to minimize disruption.

### Task 13: Persist the two settings columns end-to-end

**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs:38-55` (UpdateAsync)
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:20-29` (DTO), `:201-210` (Get), `:217-229` (Update)
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs:468` (DTO)

- [ ] **Step 1: Persist new fields in `AppSettingsRepository.UpdateAsync`**

Add inside `UpdateAsync`, before `await _context.SaveChangesAsync(ct);` (line 54):

```csharp
        row.ReportExcludedPaths = string.IsNullOrWhiteSpace(updated.ReportExcludedPaths)
            ? null : updated.ReportExcludedPaths;
        row.StandupWeekday = updated.StandupWeekday;
```

- [ ] **Step 2: Extend the Worker `AppSettingsDto`**

In `WorkerHub.cs` change the record (lines 20-29) to add two trailing fields:

```csharp
public record AppSettingsDto(
    string DefaultClaudeInstructions,
    string DefaultModel,
    int DefaultMaxTurns,
    string DefaultPermissionMode,
    int MaxParallelExecutions,
    string WorktreeStrategy,
    string? CentralWorktreeRoot,
    bool WorktreeAutoCleanupEnabled,
    int WorktreeAutoCleanupDays,
    string? ReportExcludedPaths,
    int StandupWeekday);
```

- [ ] **Step 3: Map them in `GetAppSettings`**

In `GetAppSettings` (line 201), add two trailing constructor args:

```csharp
            row.WorktreeAutoCleanupDays,
            row.ReportExcludedPaths,
            row.StandupWeekday);
```

- [ ] **Step 4: Map them in `UpdateAppSettings`**

In `UpdateAppSettings` (line 217 entity initializer), add:

```csharp
            WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays,
            ReportExcludedPaths = dto.ReportExcludedPaths,
            StandupWeekday = dto.StandupWeekday == 0 ? (int)DayOfWeek.Wednesday : dto.StandupWeekday,
```

(replace the existing `WorktreeAutoCleanupDays = ...,` line, which currently ends the initializer).

- [ ] **Step 5: Extend the UI `AppSettingsDto`** (`WorkerClient.cs:468`)

Add the same two trailing fields (`string? ReportExcludedPaths, int StandupWeekday`) to the UI record so the SignalR shapes match.

- [ ] **Step 6: Build**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj && dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: FAIL in `SettingsModalViewModel` (DTO now needs 11 args) — fixed in Task 14.

- [ ] **Step 7: Commit** (after Task 14 build passes — see Task 14 Step 5)

### Task 14: Settings tab fields

**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs:41-49` (load), `:68-77` (save)
- Modify: the General settings tab view (`src/ClaudeDo.Ui/Views/Modals/...` — the view bound to `GeneralSettingsTabViewModel`; locate via its `x:DataType`)

- [ ] **Step 1: Add observable properties to `GeneralSettingsTabViewModel`**

```csharp
    // Newline-separated path prefixes excluded from the weekly report.
    [ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
    // 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
    [ObservableProperty] private int _standupWeekday = (int)DayOfWeek.Wednesday;
```

- [ ] **Step 2: Load them in `SettingsModalViewModel.LoadAsync`**

Inside the `if (dto is not null)` block (after line 45):

```csharp
                General.ReportExcludedPaths = string.IsNullOrWhiteSpace(dto.ReportExcludedPaths)
                    ? @"C:\Private"
                    : string.Join(Environment.NewLine,
                        System.Text.Json.JsonSerializer.Deserialize<List<string>>(dto.ReportExcludedPaths) ?? new());
                General.StandupWeekday = dto.StandupWeekday == 0 ? (int)DayOfWeek.Wednesday : dto.StandupWeekday;
```

- [ ] **Step 3: Save them in `SettingsModalViewModel.Save`**

Change the `new AppSettingsDto(...)` (lines 68-77) to append the two new args:

```csharp
                Worktrees.WorktreeAutoCleanupDays,
                System.Text.Json.JsonSerializer.Serialize(
                    General.ReportExcludedPaths
                        .Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
                General.StandupWeekday);
```

- [ ] **Step 4: Add the UI to the General settings tab view**

In the General tab view's layout, add (German labels, matching nearby control styles):

```xml
<TextBlock Classes="meta" Text="Bericht: ausgeschlossene Pfade (einer pro Zeile)"/>
<TextBox AcceptsReturn="True" MinHeight="60"
         Text="{Binding ReportExcludedPaths}"/>
<TextBlock Classes="meta" Text="Standup-Wochentag"/>
<ComboBox SelectedIndex="{Binding StandupWeekday}">
  <ComboBoxItem>Sonntag</ComboBoxItem><ComboBoxItem>Montag</ComboBoxItem>
  <ComboBoxItem>Dienstag</ComboBoxItem><ComboBoxItem>Mittwoch</ComboBoxItem>
  <ComboBoxItem>Donnerstag</ComboBoxItem><ComboBoxItem>Freitag</ComboBoxItem>
  <ComboBoxItem>Samstag</ComboBoxItem>
</ComboBox>
```

(`SelectedIndex` maps directly to `System.DayOfWeek` 0–6.)

- [ ] **Step 5: Build and commit Tasks 13–14**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj && dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

```bash
git add src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/Modals/
git commit -m "feat(settings): persist report excluded paths and standup weekday"
```

---

## Phase 5 — Weekly Report modal

### Task 15: WeeklyReportModalViewModel (+ default-range unit test)

**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WeeklyReportRangeTests.cs`

- [ ] **Step 1: Write the failing test for the default range**

```csharp
using ClaudeDo.Ui.ViewModels.Modals;

namespace ClaudeDo.Ui.Tests.ViewModels;

public class WeeklyReportRangeTests
{
    [Fact]
    public void DefaultRange_TodayIsStandupDay_GoesBackToPreviousStandup()
    {
        // Wednesday 2026-06-03; previous Wednesday is 2026-05-27
        var (start, end) = WeeklyReportModalViewModel.DefaultRange(
            DayOfWeek.Wednesday, new DateOnly(2026, 6, 3));
        Assert.Equal(new DateOnly(2026, 5, 27), start);
        Assert.Equal(new DateOnly(2026, 6, 3), end);
    }

    [Fact]
    public void DefaultRange_MidWeek_StartsAtMostRecentStandup()
    {
        // Friday 2026-06-05; most recent Wednesday is 2026-06-03
        var (start, end) = WeeklyReportModalViewModel.DefaultRange(
            DayOfWeek.Wednesday, new DateOnly(2026, 6, 5));
        Assert.Equal(new DateOnly(2026, 6, 3), start);
        Assert.Equal(new DateOnly(2026, 6, 5), end);
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter WeeklyReportRangeTests`
Expected: FAIL — type does not exist.

- [ ] **Step 3: Implement `WeeklyReportModalViewModel.cs`**

```csharp
using ClaudeDo.Ui.Services.Interfaces;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace ClaudeDo.Ui.ViewModels.Modals;

public sealed partial class WeeklyReportModalViewModel : ViewModelBase
{
    private readonly IWorkerClient _worker;

    public WeeklyReportModalViewModel(IWorkerClient worker) => _worker = worker;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(HasReport))]
    [NotifyPropertyChangedFor(nameof(EmptyStateVisible))]
    private string? _reportMarkdown;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(EmptyStateVisible))]
    [NotifyCanExecuteChangedFor(nameof(GenerateCommand))]
    private bool _isBusy;

    [ObservableProperty] private DateTime? _startDate;
    [ObservableProperty] private DateTime? _endDate;
    [ObservableProperty] private string _statusMessage = "";

    public bool HasReport => !string.IsNullOrWhiteSpace(ReportMarkdown);
    public bool EmptyStateVisible => !HasReport && !IsBusy;

    public Action? CloseAction { get; set; }
    [RelayCommand] private void Close() => CloseAction?.Invoke();

    /// <summary>Range from the most recent standup weekday up to today (inclusive).
    /// If today IS the standup day, go back to the previous standup.</summary>
    public static (DateOnly Start, DateOnly End) DefaultRange(DayOfWeek standup, DateOnly today)
    {
        int diff = ((int)today.DayOfWeek - (int)standup + 7) % 7;
        if (diff == 0) diff = 7;
        return (today.AddDays(-diff), today);
    }

    public async Task InitializeAsync()
    {
        var standup = DayOfWeek.Wednesday;
        var settings = await _worker.GetAppSettingsAsync();
        if (settings is not null && settings.StandupWeekday is >= 0 and <= 6)
            standup = (DayOfWeek)settings.StandupWeekday;

        var (start, end) = DefaultRange(standup, DateOnly.FromDateTime(DateTime.Today));
        StartDate = start.ToDateTime(TimeOnly.MinValue);
        EndDate = end.ToDateTime(TimeOnly.MinValue);
        await LoadStoredAsync();
    }

    partial void OnStartDateChanged(DateTime? value) => _ = LoadStoredAsync();
    partial void OnEndDateChanged(DateTime? value) => _ = LoadStoredAsync();

    private bool RangeValid => StartDate is not null && EndDate is not null && StartDate <= EndDate;

    private async Task LoadStoredAsync()
    {
        if (!RangeValid) return;
        StatusMessage = "";
        try
        {
            ReportMarkdown = await _worker.GetWeekReportAsync(
                DateOnly.FromDateTime(StartDate!.Value), DateOnly.FromDateTime(EndDate!.Value));
        }
        catch (Exception ex) { StatusMessage = ex.Message; }
    }

    private bool CanGenerate() => !IsBusy;

    [RelayCommand(CanExecute = nameof(CanGenerate))]
    private async Task Generate()
    {
        if (!RangeValid) { StatusMessage = "Ungültiger Zeitraum."; return; }
        IsBusy = true;
        StatusMessage = "Bericht wird erstellt…";
        try
        {
            ReportMarkdown = await _worker.GenerateWeekReportAsync(
                DateOnly.FromDateTime(StartDate!.Value), DateOnly.FromDateTime(EndDate!.Value));
            StatusMessage = "";
        }
        catch (Exception ex) { StatusMessage = $"Fehler: {ex.Message}"; }
        finally { IsBusy = false; }
    }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter WeeklyReportRangeTests`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WeeklyReportRangeTests.cs
git commit -m "feat(ui): WeeklyReportModalViewModel with default-range logic"
```

### Task 16: WeeklyReportModalView

**Files:**
- Create: `src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml.cs`

- [ ] **Step 1: Create `WeeklyReportModalView.axaml`** (mirrors `AboutModalView` shell pattern)

```xml
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
        xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
        x:Class="ClaudeDo.Ui.Views.Modals.WeeklyReportModalView"
        x:DataType="vm:WeeklyReportModalViewModel"
        Title="Wochenbericht"
        Width="820" Height="640"
        WindowDecorations="None"
        ExtendClientAreaToDecorationsHint="True"
        WindowStartupLocation="CenterOwner"
        Background="{DynamicResource SurfaceBrush}">
  <Window.KeyBindings>
    <KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
  </Window.KeyBindings>

  <ctl:ModalShell Title="WOCHENBERICHT" CloseCommand="{Binding CloseCommand}">
    <DockPanel Margin="20,16">
      <!-- Toolbar: range + actions -->
      <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8">
        <TextBlock Classes="meta" Text="Von" VerticalAlignment="Center"/>
        <ctl:ThemedDatePicker SelectedDate="{Binding StartDate}"/>
        <TextBlock Classes="meta" Text="Bis" VerticalAlignment="Center"/>
        <ctl:ThemedDatePicker SelectedDate="{Binding EndDate}"/>
        <Button Classes="btn" Content="Erstellen" Command="{Binding GenerateCommand}"
                IsVisible="{Binding EmptyStateVisible}"/>
        <Button Classes="btn" Content="Neu erstellen" Command="{Binding GenerateCommand}"
                IsVisible="{Binding HasReport}"/>
      </StackPanel>

      <TextBlock DockPanel.Dock="Top" Classes="meta" Margin="0,8,0,0"
                 Text="{Binding StatusMessage}"/>

      <!-- Empty state -->
      <TextBlock DockPanel.Dock="Top" Classes="meta" Margin="0,16"
                 Text="Noch kein Bericht für diesen Zeitraum. Klicke „Erstellen“."
                 IsVisible="{Binding EmptyStateVisible}"/>

      <!-- Report body -->
      <ScrollViewer IsVisible="{Binding HasReport}">
        <ctl:MarkdownView Markdown="{Binding ReportMarkdown}"/>
      </ScrollViewer>
    </DockPanel>
  </ctl:ModalShell>
</Window>
```

- [ ] **Step 2: Create `WeeklyReportModalView.axaml.cs`**

```csharp
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

namespace ClaudeDo.Ui.Views.Modals;

public partial class WeeklyReportModalView : Window
{
    public WeeklyReportModalView() => InitializeComponent();
}
```

- [ ] **Step 3: Build**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml.cs
git commit -m "feat(ui): WeeklyReportModalView"
```

### Task 17: Wire the modal (DI + shell command + menu + host)

**Files:**
- Modify: `src/ClaudeDo.App/Program.cs:100-101` (DI, mirror WorktreesOverview)
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` (factory field, Show hook, open command)
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` (assign `ShowWeeklyReportModal`)
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (Help menu item)

- [ ] **Step 1: Register the VM + factory in `App/Program.cs`** (after line 101)

```csharp
        sc.AddTransient<WeeklyReportModalViewModel>();
        sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
```

- [ ] **Step 2: Add factory + hook + command to `IslandsShellViewModel`**

Mirror the WorktreesOverview members. Add fields/props:

```csharp
    private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
    public Func<WeeklyReportModalViewModel, Task>? ShowWeeklyReportModal { get; set; }
```

Add the constructor parameter `Func<WeeklyReportModalViewModel> weeklyReportVmFactory` and assign `_weeklyReportVmFactory = weeklyReportVmFactory;`. Then the command:

```csharp
    private bool _weeklyReportOpen;

    [RelayCommand]
    private async Task OpenWeeklyReport()
    {
        if (ShowWeeklyReportModal is null || _weeklyReportOpen) return;
        _weeklyReportOpen = true;
        try
        {
            var vm = _weeklyReportVmFactory();
            await vm.InitializeAsync();
            await ShowWeeklyReportModal(vm);
        }
        finally { _weeklyReportOpen = false; }
    }
```

- [ ] **Step 3: Assign `ShowWeeklyReportModal` in `MainWindow.axaml.cs`** (next to `ShowWorktreesOverviewModal`, ~line 55)

```csharp
            vm.ShowWeeklyReportModal = async (modal) =>
            {
                var dlg = new WeeklyReportModalView { DataContext = modal };
                modal.CloseAction = () => dlg.Close();
                await dlg.ShowDialog(this);
            };
```

- [ ] **Step 4: Add a Help-menu item in `MainWindow.axaml`** (next to the Worktrees Overview item, ~line 67)

```xml
              <MenuItem Header="Wochenbericht…" Command="{Binding OpenWeeklyReportCommand}"/>
```

- [ ] **Step 5: Build and smoke-launch**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS. (Full manual check in Phase 7.)

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): open Weekly Report modal from the menu"
```

---

## Phase 6 — Notes in My Day

Mirrors the existing `IPrimeScheduleApi`/`WorkerPrimeScheduleApi` pattern: a thin `INotesApi` keeps `NotesEditorViewModel` testable without faking all of `IWorkerClient`.

### Task 18: INotesApi + WorkerNotesApi + DI

**Files:**
- Create: `src/ClaudeDo.Ui/Services/Interfaces/INotesApi.cs`
- Create: `src/ClaudeDo.Ui/Services/WorkerNotesApi.cs`
- Modify: `src/ClaudeDo.App/Program.cs` (register, near `WorkerPrimeScheduleApi`)

- [ ] **Step 1: Create `INotesApi.cs`**

```csharp
namespace ClaudeDo.Ui.Services.Interfaces;

public interface INotesApi
{
    Task<List<DailyNoteDto>> ListAsync(DateOnly day);
    Task<DailyNoteDto?> AddAsync(DateOnly day, string text);
    Task UpdateAsync(string id, string text);
    Task DeleteAsync(string id);
}
```

- [ ] **Step 2: Create `WorkerNotesApi.cs`**

```csharp
using ClaudeDo.Ui.Services.Interfaces;

namespace ClaudeDo.Ui.Services;

public sealed class WorkerNotesApi : INotesApi
{
    private readonly WorkerClient _client;
    public WorkerNotesApi(WorkerClient client) => _client = client;
    public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
    public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
    public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);
    public Task DeleteAsync(string id) => _client.DeleteDailyNoteAsync(id);
}
```

- [ ] **Step 3: Register in `App/Program.cs`** (find where `WorkerPrimeScheduleApi`/`IPrimeScheduleApi` is registered and add alongside)

```csharp
        sc.AddSingleton<INotesApi, WorkerNotesApi>();
```

- [ ] **Step 4: Build**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Ui/Services/Interfaces/INotesApi.cs src/ClaudeDo.Ui/Services/WorkerNotesApi.cs src/ClaudeDo.App/Program.cs
git commit -m "feat(ui): INotesApi wrapper for daily notes"
```

### Task 19: NotesEditorViewModel + NoteBulletViewModel (TDD)

**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/NotesEditorViewModelTests.cs`

- [ ] **Step 1: Write the failing test**

```csharp
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels.Islands;

namespace ClaudeDo.Ui.Tests.ViewModels;

public class NotesEditorViewModelTests
{
    private sealed class FakeNotes : INotesApi
    {
        public readonly List<DailyNoteDto> Store = new();
        private int _seq;
        public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
            Task.FromResult(Store.Where(n => n.Date == day.ToString("yyyy-MM-dd")).ToList());
        public Task<DailyNoteDto?> AddAsync(DateOnly day, string text)
        {
            var dto = new DailyNoteDto($"id{_seq++}", day.ToString("yyyy-MM-dd"), text, Store.Count);
            Store.Add(dto);
            return Task.FromResult<DailyNoteDto?>(dto);
        }
        public Task UpdateAsync(string id, string text)
        {
            var i = Store.FindIndex(n => n.Id == id);
            if (i >= 0) Store[i] = Store[i] with { Text = text };
            return Task.CompletedTask;
        }
        public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; }
    }

    [Fact]
    public async Task AddBullet_PersistsAndAppears_ForCurrentDay()
    {
        var api = new FakeNotes();
        var vm = new NotesEditorViewModel(api);
        await vm.LoadDayAsync(new DateOnly(2026, 6, 1));

        vm.NewBulletText = "Standup vorbereitet";
        await vm.AddBulletCommand.ExecuteAsync(null);

        Assert.Single(vm.Bullets);
        Assert.Equal("Standup vorbereitet", vm.Bullets[0].Text);
        Assert.Equal("", vm.NewBulletText);
        Assert.Single(api.Store);
    }

    [Fact]
    public async Task PrevAndNextDay_NavigateAndReload()
    {
        var api = new FakeNotes();
        await api.AddAsync(new DateOnly(2026, 5, 31), "gestern");
        var vm = new NotesEditorViewModel(api);
        await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
        Assert.Empty(vm.Bullets);

        await vm.PrevDayCommand.ExecuteAsync(null);
        Assert.Equal(new DateOnly(2026, 5, 31), vm.CurrentDay);
        Assert.Single(vm.Bullets);

        await vm.NextDayCommand.ExecuteAsync(null);
        Assert.Equal(new DateOnly(2026, 6, 1), vm.CurrentDay);
        Assert.Empty(vm.Bullets);
    }

    [Fact]
    public async Task DeleteBullet_RemovesFromStoreAndList()
    {
        var api = new FakeNotes();
        var vm = new NotesEditorViewModel(api);
        await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
        vm.NewBulletText = "weg damit";
        await vm.AddBulletCommand.ExecuteAsync(null);

        await vm.Bullets[0].DeleteCommand.ExecuteAsync(null);

        Assert.Empty(vm.Bullets);
        Assert.Empty(api.Store);
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter NotesEditorViewModelTests`
Expected: FAIL — types do not exist.

- [ ] **Step 3: Implement `NotesEditorViewModel.cs`** (contains both VMs)

```csharp
using System.Collections.ObjectModel;
using ClaudeDo.Ui.Services.Interfaces;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace ClaudeDo.Ui.ViewModels.Islands;

public sealed partial class NoteBulletViewModel : ViewModelBase
{
    private readonly Func<NoteBulletViewModel, Task> _save;
    private readonly Func<NoteBulletViewModel, Task> _delete;

    public string Id { get; }

    [ObservableProperty] private string _text;

    public NoteBulletViewModel(string id, string text,
        Func<NoteBulletViewModel, Task> save, Func<NoteBulletViewModel, Task> delete)
    {
        Id = id;
        _text = text;
        _save = save;
        _delete = delete;
    }

    [RelayCommand] private Task Save() => _save(this);
    [RelayCommand] private Task Delete() => _delete(this);
}

public sealed partial class NotesEditorViewModel : ViewModelBase
{
    private readonly INotesApi _api;

    public NotesEditorViewModel(INotesApi api) => _api = api;

    public ObservableCollection<NoteBulletViewModel> Bullets { get; } = new();

    [ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today);
    [ObservableProperty] private string _newBulletText = "";

    // For the date picker (two-way).
    public DateTime CurrentDate
    {
        get => CurrentDay.ToDateTime(TimeOnly.MinValue);
        set { var d = DateOnly.FromDateTime(value); if (d != CurrentDay) _ = LoadDayAsync(d); }
    }

    public string CurrentDayLabel => CurrentDay.ToString("dddd, dd.MM.yyyy");

    public async Task LoadDayAsync(DateOnly day)
    {
        CurrentDay = day;
        OnPropertyChanged(nameof(CurrentDate));
        OnPropertyChanged(nameof(CurrentDayLabel));
        Bullets.Clear();
        foreach (var dto in await _api.ListAsync(day))
            Bullets.Add(MakeBullet(dto.Id, dto.Text));
    }

    private NoteBulletViewModel MakeBullet(string id, string text) =>
        new(id, text, SaveBulletAsync, DeleteBulletAsync);

    [RelayCommand]
    private async Task AddBullet()
    {
        var text = NewBulletText.Trim();
        if (text.Length == 0) return;
        var dto = await _api.AddAsync(CurrentDay, text);
        if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text));
        NewBulletText = "";
    }

    [RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1));
    [RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
    [RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));

    private Task SaveBulletAsync(NoteBulletViewModel b) => _api.UpdateAsync(b.Id, b.Text);

    private async Task DeleteBulletAsync(NoteBulletViewModel b)
    {
        await _api.DeleteAsync(b.Id);
        Bullets.Remove(b);
    }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter NotesEditorViewModelTests`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/NotesEditorViewModelTests.cs
git commit -m "feat(ui): NotesEditorViewModel with day navigation and bullet CRUD"
```

### Task 20: NotesEditorView

**Files:**
- Create: `src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml.cs`

- [ ] **Step 1: Create `NotesEditorView.axaml`** (a UserControl hosted by the Details island)

```xml
<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
             xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
             x:Class="ClaudeDo.Ui.Views.Islands.NotesEditorView"
             x:DataType="vm:NotesEditorViewModel">
  <DockPanel Margin="16">
    <!-- Day navigator -->
    <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8">
      <Button Classes="btn" Content="‹" Command="{Binding PrevDayCommand}"/>
      <ctl:ThemedDatePicker SelectedDate="{Binding CurrentDate, Mode=TwoWay}"/>
      <Button Classes="btn" Content="›" Command="{Binding NextDayCommand}"/>
      <Button Classes="btn" Content="Heute" Command="{Binding TodayCommand}"/>
      <TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding CurrentDayLabel}"/>
    </StackPanel>

    <!-- Add bullet -->
    <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,12,0,8">
      <TextBox Width="420" Watermark="Neue Notiz…" Text="{Binding NewBulletText}"/>
      <Button Classes="btn" Content="Hinzufügen" Command="{Binding AddBulletCommand}"/>
    </StackPanel>

    <!-- Bullets -->
    <ScrollViewer>
      <ItemsControl ItemsSource="{Binding Bullets}">
        <ItemsControl.ItemTemplate>
          <DataTemplate x:DataType="vm:NoteBulletViewModel">
            <Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2" ColumnSpacing="6">
              <TextBox Grid.Column="0" Text="{Binding Text}"/>
              <Button Grid.Column="1" Classes="btn" Content="Speichern" Command="{Binding SaveCommand}"/>
              <Button Grid.Column="2" Classes="btn" Content="Löschen" Command="{Binding DeleteCommand}"/>
            </Grid>
          </DataTemplate>
        </ItemsControl.ItemTemplate>
      </ItemsControl>
    </ScrollViewer>
  </DockPanel>
</UserControl>
```

- [ ] **Step 2: Create `NotesEditorView.axaml.cs`**

```csharp
using Avalonia.Controls;

namespace ClaudeDo.Ui.Views.Islands;

public partial class NotesEditorView : UserControl
{
    public NotesEditorView() => InitializeComponent();
}
```

- [ ] **Step 3: Build**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml.cs
git commit -m "feat(ui): NotesEditorView"
```

### Task 21: Details island notes mode

`DetailsIslandViewModel` hosts a `NotesEditorViewModel` and an `IsNotesMode` flag. `Bind(row)` turns notes mode OFF; `ShowNotes()` turns it ON and clears the bound task. The view shows the task panel or the notes editor based on the flag.

**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (constructor + flag + ShowNotes + Bind)
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` (host NotesEditorView)
- Modify: `src/ClaudeDo.App/Program.cs` (ensure `DetailsIslandViewModel` can resolve `INotesApi`)

- [ ] **Step 1: Add notes-mode members to `DetailsIslandViewModel`**

Add an `INotesApi` constructor parameter (store as `_notesApi`) and:

```csharp
    [ObservableProperty] private bool _isNotesMode;
    public NotesEditorViewModel Notes { get; }
```

In the constructor, after assigning dependencies:

```csharp
        Notes = new NotesEditorViewModel(_notesApi);
```

Add the entry method:

```csharp
    public void ShowNotes()
    {
        Bind(null);             // clears the task panel/log
        IsNotesMode = true;
        _ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
    }
```

- [ ] **Step 2: Turn notes mode OFF inside `Bind`**

At the top of `Bind` (line 434, right after entering), add:

```csharp
        IsNotesMode = false;
```

- [ ] **Step 3: Verify DI for the new constructor param**

`DetailsIslandViewModel` is resolved by the container. Confirm `INotesApi` is registered (Task 18) so the new parameter injects. Build will fail if not.

- [ ] **Step 4: Host the notes editor in `DetailsIslandView.axaml`**

Wrap the existing details content so it is visible only when NOT in notes mode (e.g. add `IsVisible="{Binding !IsNotesMode}"` to the root content panel), and add a sibling notes editor (add `xmlns:isl="using:ClaudeDo.Ui.Views.Islands"`):

```xml
<isl:NotesEditorView DataContext="{Binding Notes}"
                     IsVisible="{Binding $parent[UserControl].((vm:DetailsIslandViewModel)DataContext).IsNotesMode}"/>
```

(Place it as a sibling of the task content in the island's root layout. If the root is a single panel, wrap both in a `Grid` so they overlay; the `IsVisible` flags make them mutually exclusive.)

- [ ] **Step 5: Build**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
git commit -m "feat(ui): notes mode in the Details island"
```

### Task 22: Pinned Notes row in My Day + selection routing

A pinned "Notes" row sits at the top of the Tasks island when the My Day smart list is active. Clicking it routes to `Details.ShowNotes()`. Selecting any real task routes through the existing `Details.Bind(...)` (which clears notes mode).

**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (visibility flag + event + command)
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` (pinned row UI)
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` (route event to Details)

- [ ] **Step 1: Add notes-row state to `TasksIslandViewModel`**

```csharp
    public event Action? NotesRequested;

    // True only for the My Day smart list, so the pinned Notes row shows there.
    [ObservableProperty] private bool _showNotesRow;

    [RelayCommand]
    private void OpenNotes()
    {
        SelectedTask = null;          // visually deselect any task
        NotesRequested?.Invoke();
    }
```

Set `ShowNotesRow` where the active list is resolved (the same place `smart:my-day` is matched for filtering — see line 144 / the list-load path): `ShowNotesRow = list.Id == "smart:my-day";`.

- [ ] **Step 2: Add the pinned row to `TasksIslandView.axaml`**

Above the task list (`Items`/`OpenItems` ListBox), add a row visible only for My Day:

```xml
<Button Classes="btn" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
        Margin="0,0,0,8"
        IsVisible="{Binding ShowNotesRow}"
        Command="{Binding OpenNotesCommand}"
        Content="📝 Notizen (Tagesnotizen)"/>
```

(Match the surrounding row styling; the icon glyph is optional.)

- [ ] **Step 3: Route the event in `IslandsShellViewModel`**

Where `Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);` is wired (line 193), add:

```csharp
        Tasks.NotesRequested += () => Details.ShowNotes();
```

- [ ] **Step 4: Build**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
git commit -m "feat(ui): pinned Notes row in My Day opens the notes editor"
```

---

## Phase 7 — Full verification

### Task 23: Build everything + run all tests

- [ ] **Step 1: Build all projects**

Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj && dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.

- [ ] **Step 2: Run the full test suites touched by this work**

Run: `dotnet test tests/ClaudeDo.Worker.Tests && dotnet test tests/ClaudeDo.Ui.Tests`
Expected: PASS (all green, including the new DailyNote/WeekReport repo, reader, prompt-builder, service, range, and notes-editor tests).

### Task 24: Manual smoke test (UI feature correctness)

Type/test checks verify code, not feature behavior. Do this manually:

- [ ] **Step 1:** Start the Worker, then the App.
- [ ] **Step 2:** Settings → set an excluded path (`C:\Private`) and standup weekday (Mittwoch). Save.
- [ ] **Step 3:** Select **My Day** → confirm the pinned **Notizen** row appears at the top. Click it → the Details island switches to the notes editor.
- [ ] **Step 4:** Add 2 bullets for today; step back a day with `‹`, add one for yesterday; jump via the date picker; use **Heute**. Edit and delete a bullet. Confirm persistence by reselecting the day.
- [ ] **Step 5:** Select a real task → confirm the Details island returns to task mode (notes editor hidden).
- [ ] **Step 6:** Help menu → **Wochenbericht…**. Confirm the default range is "letzter Mittwoch → heute". With no stored report, the **Erstellen** button shows.
- [ ] **Step 7:** Click **Erstellen** → busy state → a German, day-grouped markdown report renders, blending your bullet notes; repos under `C:\Private` are absent.
- [ ] **Step 8:** Close and reopen the modal for the same range → the stored report renders immediately with no Claude call. **Neu erstellen** overwrites it.
- [ ] **Step 9:** Pick a range with no activity/notes → report shows "Keine Aktivität in diesem Zeitraum."

### Task 25: Update docs

- [ ] **Step 1:** Update `CLAUDE.md` files where the spec touches documented surfaces:
  - `src/ClaudeDo.Data/CLAUDE.md` — add `DailyNoteEntity`, `WeekReportEntity`, their repositories, and the new `app_settings` columns + `daily_notes`/`week_reports` tables.
  - `src/ClaudeDo.Worker/CLAUDE.md` — add the `Report/` folder and the new hub methods (`GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`).
  - `src/ClaudeDo.Ui/CLAUDE.md` — note the Weekly Report modal, the Notes editor / My Day pinned row, and the new `WorkerClient` methods.
- [ ] **Step 2: Commit**

```bash
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs: document weekly report and daily notes feature"
```

---

## Self-Review

**Spec coverage:**
- All-history data source → Task 7 (`ClaudeHistoryReader`).
- Configurable excluded paths → Tasks 1/13/14 (settings) + Task 9 (applied) + Task 7 (enforced).
- Claude summarization (one-shot, no worktree) → Task 9.
- Default "since last Wednesday" range → Task 15 (`DefaultRange`) + standup weekday setting.
- Signal = prompts + closing summaries + notes → Task 7 (prompts/last-assistant) + Task 8/9 (notes blended).
- German, day-grouped, first-person, ≤3–5 bullets, notes merged → Task 8 (`WeekReportPromptBuilder`).
- Report modal, view-only, button-driven, cached/persisted → Tasks 5/9/15/16/17.
- Notes in My Day via pinned pseudo-row repurposing the Details island, day navigator (arrows + date picker + Today), per-day bullets → Tasks 18–22.
- Persistence keyed by range, reused, regenerate overwrites → Task 5 (repo) + Task 9 (upsert) + Task 15/16 (UI).
- Error handling (malformed skip, empty window, Claude failure) → Tasks 7/9.
- Tests (reader/service/prompt/repos + UI VMs) → Tasks 4,5,7,8,9,15,19.

**Type consistency:** `DailyNoteDto(Id, Date, Text, SortOrder)` identical on both sides (Tasks 11/12). `AppSettingsDto` extended with the same two trailing fields on both sides (Task 13). `IWeekReportService.GenerateAsync/GetStoredAsync`, `IClaudeHistoryReader.ReadAsync`, `INotesApi` signatures match their callers. Hub method names match `WorkerClient` invoke strings (`GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`).

**Known soft spots (need a quick read during implementation, not placeholders):** the exact General-settings-tab view file (Task 14 Step 4), the Details island root layout for hosting the notes editor (Task 21 Step 4), and the Tasks island list container for the pinned row (Task 22 Step 2). Each task names the file, the members to add, and the surrounding pattern to match.

