Files
ClaudeDo/docs/superpowers/plans/2026-06-03-weekly-report.md
2026-06-03 09:19:08 +02:00

86 KiB
Raw Permalink Blame History

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

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
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):

    // 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
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

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
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):

        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>();):

    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
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
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

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
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
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

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
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
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

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
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
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

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
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
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

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
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
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

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
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
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
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;):

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
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

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)
    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
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

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:

    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:

    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
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):

        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:

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:

            row.WorktreeAutoCleanupDays,
            row.ReportExcludedPaths,
            row.StandupWeekday);
  • Step 4: Map them in UpdateAppSettings

In UpdateAppSettings (line 217 entity initializer), add:

            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

    // 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):

                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:

                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):

<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 06.)

  • Step 5: Build and commit Tasks 1314

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

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

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
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
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)

<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
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
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)

        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:

    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:

    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)
            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)
              <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
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

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
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)
        sc.AddSingleton<INotesApi, WorkerNotesApi>();
  • Step 4: Build

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

  • Step 5: Commit
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

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)
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
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)

<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
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
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:

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

In the constructor, after assigning dependencies:

        Notes = new NotesEditorViewModel(_notesApi);

Add the entry method:

    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:

        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"):

<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
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

    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:

<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:

        Tasks.NotesRequested += () => Details.ShowNotes();
  • Step 4: Build

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

  • Step 5: Commit
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
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, ≤35 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 1822.
  • 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.