From f72cfae7d9f936418fd75cb10c70abc1eba0f3d0 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 3 Jun 2026 09:19:08 +0200 Subject: [PATCH] docs: add weekly report implementation plan --- .../plans/2026-06-03-weekly-report.md | 2311 +++++++++++++++++ 1 file changed, 2311 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-weekly-report.md diff --git a/docs/superpowers/plans/2026-06-03-weekly-report.md b/docs/superpowers/plans/2026-06-03-weekly-report.md new file mode 100644 index 0000000..9f08958 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-weekly-report.md @@ -0,0 +1,2311 @@ +# 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 +{ + public void Configure(EntityTypeBuilder 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 +{ + public void Configure(EntityTypeBuilder 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 PrimeSchedules => Set();`): + +```csharp + public DbSet DailyNotes => Set(); + public DbSet WeekReports => Set(); +``` + +- [ ] **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> ListByDayAsync(DateOnly day, CancellationToken ct = default) => + await _context.DailyNotes.AsNoTracking() + .Where(n => n.Date == day) + .OrderBy(n => n.SortOrder) + .ToListAsync(ct); + + public async Task> 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 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 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 Prompts { get; } = new(); + public List Summaries { get; } = new(); +} + +public sealed class RepoActivity +{ + public required string RepoPath { get; init; } + public List Days { get; } = new(); +} +``` + +- [ ] **Step 2: Create `Interfaces/IClaudeHistoryReader.cs`** + +```csharp +namespace ClaudeDo.Worker.Report.Interfaces; + +public interface IClaudeHistoryReader +{ + Task> ReadAsync( + DateOnly start, + DateOnly end, + IReadOnlyList 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 `/*/*.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 `` 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()); + + 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 blah")); + + var reader = new ClaudeHistoryReader(_root); + var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3), + Array.Empty()); + + 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> ReadAsync( + DateOnly start, DateOnly end, IReadOnlyList 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>(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("", 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(); + 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> + { + [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 activity, + IReadOnlyDictionary> 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>(); + 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 GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default); + Task 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 Result = Array.Empty(); + public Task> ReadAsync( + DateOnly s, DateOnly e, IReadOnlyList ex, CancellationToken ct) => Task.FromResult(Result); + } + + private sealed class FakeClaude : IClaudeProcess + { + public int Calls; + public RunResult Next = new() { ExitCode = 0, ResultMarkdown = "## Bericht" }; + public Task RunAsync(string args, string prompt, string wd, Func onLine, CancellationToken ct) + { Calls++; return Task.FromResult(Next); } + } + + private WeekReportService Make(FakeReader reader, FakeClaude claude) => + new(reader, _db.CreateFactory(), claude, NullLogger.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(() => 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 _dbFactory; + private readonly IClaudeProcess _claude; + private readonly ILogger _logger; + + public WeekReportService( + IClaudeHistoryReader reader, + IDbContextFactory dbFactory, + IClaudeProcess claude, + ILogger logger) + { + _reader = reader; + _dbFactory = dbFactory; + _claude = claude; + _logger = logger; + } + + public async Task 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 GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default) + { + string[] excludes; + string model; + IReadOnlyList 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>(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(_ => + new ClaudeHistoryReader(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects"))); +builder.Services.AddSingleton(); +``` + +- [ ] **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 GetWeekReport(string startIso, string endIso) => + _report.GetStoredAsync(Day(startIso), Day(endIso)); + + public Task GenerateWeekReport(string startIso, string endIso) => + HubGuard(() => _report.GenerateAsync(Day(startIso), Day(endIso)), "report generation failed"); + + public async Task> 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 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` already exist: + +```csharp + private static string IsoDay(DateOnly d) => d.ToString("yyyy-MM-dd"); + + public Task GetWeekReportAsync(DateOnly start, DateOnly end) + => TryInvokeAsync("GetWeekReport", IsoDay(start), IsoDay(end)); + + public Task GenerateWeekReportAsync(DateOnly start, DateOnly end) + => TryInvokeAsync("GenerateWeekReport", IsoDay(start), IsoDay(end)); + + public async Task> GetDailyNotesAsync(DateOnly day) + => await TryInvokeAsync>("GetDailyNotes", IsoDay(day)) ?? new List(); + + public Task AddDailyNoteAsync(DateOnly day, string text) + => TryInvokeAsync("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("GenerateWeekReport", ...)` directly instead. + +- [ ] **Step 2b: Declare the same six methods on `IWorkerClient`** + +Add to `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`: + +```csharp + Task GetWeekReportAsync(DateOnly start, DateOnly end); + Task GenerateWeekReportAsync(DateOnly start, DateOnly end); + Task> GetDailyNotesAsync(DateOnly day); + Task 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>(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 + + + + + SonntagMontag + DienstagMittwoch + DonnerstagFreitag + Samstag + +``` + +(`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(); + + /// Range from the most recent standup weekday up to today (inclusive). + /// If today IS the standup day, go back to the previous standup. + 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 + + + + + + + + + + + + + +