# 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