86 KiB
Weekly Report Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Generate a short, German, standup-focused weekly report from the user's Claude Code session history (excluding configurable private paths), enriched with per-day bullet notes authored in the My Day list.
Architecture: A new Report/ area in the Worker scans ~/.claude/projects/*/*.jsonl, distills prompts + closing summaries, and runs a one-shot claude -p call via the existing ClaudeProcess to produce markdown that is stored (keyed by date range) and reused. Daily notes live in SQLite and are authored via a pinned non-task "Notes" row in the My Day list that flips the Details island into a bullet-notes editor. The UI talks to the Worker over the existing SignalR hub.
Tech Stack: .NET 8, EF Core (SQLite), SignalR, Avalonia + CommunityToolkit.Mvvm, xUnit.
Build/test note: .slnx needs .NET 9. On .NET 8 build individual projects: dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj, dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj, dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj. Tests: dotnet test tests/ClaudeDo.Worker.Tests.
Spec: docs/superpowers/specs/2026-06-03-weekly-report-design.md
File Structure
Data (src/ClaudeDo.Data)
- Create
Models/DailyNoteEntity.cs,Models/WeekReportEntity.cs - Create
Configuration/DailyNoteEntityConfiguration.cs,Configuration/WeekReportEntityConfiguration.cs - Create
Repositories/DailyNoteRepository.cs,Repositories/WeekReportRepository.cs - Modify
Models/AppSettingsEntity.cs(+ReportExcludedPaths,StandupWeekday) - Modify
ClaudeDoDbContext.cs(+ two DbSets) - Modify
Configuration/AppSettingsEntityConfiguration.cs(map new columns) - Generated:
Migrations/*_WeeklyReport.cs
Worker (src/ClaudeDo.Worker/Report/)
- Create
Interfaces/IClaudeHistoryReader.cs,Interfaces/IWeekReportService.cs - Create
ReportModels.cs(RepoActivity, DayActivity) - Create
ClaudeHistoryReader.cs,WeekReportPromptBuilder.cs,WeekReportService.cs - Modify
Hub/WorkerHub.cs(+ report & notes hub methods),Program.cs(DI),Config/WorkerConfig.cs(claude projects root override, optional)
IPC (src/ClaudeDo.Ui/Services)
- Create
ReportDtos.cs(DailyNoteDto) - Modify
WorkerClient.cs(+ methods)
UI (src/ClaudeDo.Ui)
- Create
ViewModels/Modals/WeeklyReportModalViewModel.cs,Views/Modals/WeeklyReportModalView.axaml(.cs) - Create
ViewModels/Islands/NotesEditorViewModel.cs,Views/Islands/NotesEditorView.axaml(.cs) - Modify
ViewModels/Islands/ListsIslandViewModel.cs(pinned Notes row),ViewModels/Islands/DetailsIslandViewModel.cs(notes mode),Views/Islands/DetailsIslandView.axaml(host notes editor) - Modify
ViewModels/IslandsShellViewModel.cs(open report modal, route Notes selection),Views/MainWindow.axaml(.cs)(menu item + modal host),src/ClaudeDo.App/Program.cs(DI)
Phase 1 — Data layer
Task 1: DailyNoteEntity + WeekReportEntity + AppSettings columns
Files:
-
Create:
src/ClaudeDo.Data/Models/DailyNoteEntity.cs -
Create:
src/ClaudeDo.Data/Models/WeekReportEntity.cs -
Modify:
src/ClaudeDo.Data/Models/AppSettingsEntity.cs -
Step 1: Create
DailyNoteEntity.cs
namespace ClaudeDo.Data.Models;
public sealed class DailyNoteEntity
{
public string Id { get; init; } = Guid.NewGuid().ToString();
public DateOnly Date { get; set; }
public string Text { get; set; } = string.Empty;
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
- Step 2: Create
WeekReportEntity.cs
namespace ClaudeDo.Data.Models;
public sealed class WeekReportEntity
{
public string Id { get; init; } = Guid.NewGuid().ToString();
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public string Markdown { get; set; } = string.Empty;
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
}
- Step 3: Add two columns to
AppSettingsEntity.cs
Add after the RepoImportFolders property (line 22):
// JSON array of path prefixes whose sessions are excluded from the weekly report.
public string? ReportExcludedPaths { get; set; }
// DayOfWeek the standup happens on; default Wednesday. Drives the report's default range.
public int StandupWeekday { get; set; } = (int)DayOfWeek.Wednesday;
- Step 4: Build the Data project
Run: dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
Expected: PASS (compiles).
- Step 5: Commit
git add src/ClaudeDo.Data/Models/DailyNoteEntity.cs src/ClaudeDo.Data/Models/WeekReportEntity.cs src/ClaudeDo.Data/Models/AppSettingsEntity.cs
git commit -m "feat(data): add daily note + week report entities and report settings"
Task 2: Entity configurations + DbContext DbSets
Files:
-
Create:
src/ClaudeDo.Data/Configuration/DailyNoteEntityConfiguration.cs -
Create:
src/ClaudeDo.Data/Configuration/WeekReportEntityConfiguration.cs -
Modify:
src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs -
Modify:
src/ClaudeDo.Data/ClaudeDoDbContext.cs:14-21 -
Step 1: Create
DailyNoteEntityConfiguration.cs
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class DailyNoteEntityConfiguration : IEntityTypeConfiguration<DailyNoteEntity>
{
public void Configure(EntityTypeBuilder<DailyNoteEntity> builder)
{
builder.ToTable("daily_notes");
builder.HasKey(n => n.Id);
builder.Property(n => n.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(n => n.Date).HasColumnName("note_date").IsRequired();
builder.Property(n => n.Text).HasColumnName("text").IsRequired();
builder.Property(n => n.SortOrder).HasColumnName("sort_order").IsRequired();
builder.Property(n => n.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasIndex(n => n.Date);
}
}
- Step 2: Create
WeekReportEntityConfiguration.cs
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class WeekReportEntityConfiguration : IEntityTypeConfiguration<WeekReportEntity>
{
public void Configure(EntityTypeBuilder<WeekReportEntity> builder)
{
builder.ToTable("week_reports");
builder.HasKey(r => r.Id);
builder.Property(r => r.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(r => r.StartDate).HasColumnName("start_date").IsRequired();
builder.Property(r => r.EndDate).HasColumnName("end_date").IsRequired();
builder.Property(r => r.Markdown).HasColumnName("markdown").IsRequired();
builder.Property(r => r.GeneratedAt).HasColumnName("generated_at").IsRequired();
builder.HasIndex(r => new { r.StartDate, r.EndDate }).IsUnique();
}
}
- Step 3: Map new AppSettings columns
In AppSettingsEntityConfiguration.cs, inside Configure, add (match the existing column-naming style in that file):
builder.Property(s => s.ReportExcludedPaths).HasColumnName("report_excluded_paths");
builder.Property(s => s.StandupWeekday).HasColumnName("standup_weekday")
.IsRequired().HasDefaultValue((int)DayOfWeek.Wednesday);
- Step 4: Add DbSets to
ClaudeDoDbContext.cs
After line 21 (public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();):
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
- Step 5: Build the Data project
Run: dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
Expected: PASS.
- Step 6: Commit
git add src/ClaudeDo.Data/Configuration/ src/ClaudeDo.Data/ClaudeDoDbContext.cs
git commit -m "feat(data): configure daily note + week report tables"
Task 3: EF migration
Files:
-
Generated:
src/ClaudeDo.Data/Migrations/*_WeeklyReport.cs(+ Designer + snapshot update) -
Step 1: Add the migration
Run from repo root:
dotnet ef migrations add WeeklyReport --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker
Expected: creates daily_notes, week_reports, and adds report_excluded_paths + standup_weekday to app_settings.
- Step 2: Inspect the generated migration
Open the new Migrations/*_WeeklyReport.cs. Confirm CreateTable("daily_notes"), CreateTable("week_reports") with the unique index on (start_date, end_date), and AddColumn for the two settings columns. No DropTable/DropColumn of existing schema.
- Step 3: Build the Worker project (validates migration compiles)
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
Expected: PASS.
- Step 4: Commit
git add src/ClaudeDo.Data/Migrations/
git commit -m "feat(data): migration for daily notes and week reports"
Task 4: DailyNoteRepository (TDD)
Files:
-
Create:
src/ClaudeDo.Data/Repositories/DailyNoteRepository.cs -
Test:
tests/ClaudeDo.Worker.Tests/Repositories/DailyNoteRepositoryTests.cs -
Step 1: Write the failing test
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Repositories;
public class DailyNoteRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
[Fact]
public async Task Add_AssignsIncrementingSortOrder_WithinDay()
{
var day = new DateOnly(2026, 6, 1);
using (var ctx = _db.CreateContext())
{
var repo = new DailyNoteRepository(ctx);
await repo.AddAsync(day, "first");
await repo.AddAsync(day, "second");
}
using var read = _db.CreateContext();
var notes = await new DailyNoteRepository(read).ListByDayAsync(day);
Assert.Equal(new[] { "first", "second" }, notes.Select(n => n.Text));
Assert.Equal(new[] { 0, 1 }, notes.Select(n => n.SortOrder));
}
[Fact]
public async Task ListBetween_ReturnsOnlyInRange_OrderedByDateThenSort()
{
using (var ctx = _db.CreateContext())
{
var repo = new DailyNoteRepository(ctx);
await repo.AddAsync(new DateOnly(2026, 5, 31), "before");
await repo.AddAsync(new DateOnly(2026, 6, 1), "in-a");
await repo.AddAsync(new DateOnly(2026, 6, 2), "in-b");
await repo.AddAsync(new DateOnly(2026, 6, 5), "after");
}
using var read = _db.CreateContext();
var notes = await new DailyNoteRepository(read)
.ListBetweenAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 2));
Assert.Equal(new[] { "in-a", "in-b" }, notes.Select(n => n.Text));
}
[Fact]
public async Task Update_And_Delete_Work()
{
var day = new DateOnly(2026, 6, 1);
string id;
using (var ctx = _db.CreateContext())
{
var n = await new DailyNoteRepository(ctx).AddAsync(day, "orig");
id = n.Id;
}
using (var ctx = _db.CreateContext())
await new DailyNoteRepository(ctx).UpdateAsync(id, "edited");
using (var ctx = _db.CreateContext())
{
var notes = await new DailyNoteRepository(ctx).ListByDayAsync(day);
Assert.Equal("edited", notes.Single().Text);
}
using (var ctx = _db.CreateContext())
await new DailyNoteRepository(ctx).DeleteAsync(id);
using var read = _db.CreateContext();
Assert.Empty(await new DailyNoteRepository(read).ListByDayAsync(day));
}
}
- Step 2: Run the test to verify it fails
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter DailyNoteRepositoryTests
Expected: FAIL — DailyNoteRepository does not exist (compile error).
- Step 3: Implement
DailyNoteRepository.cs
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class DailyNoteRepository
{
private readonly ClaudeDoDbContext _context;
public DailyNoteRepository(ClaudeDoDbContext context) => _context = context;
public async Task<IReadOnlyList<DailyNoteEntity>> ListByDayAsync(DateOnly day, CancellationToken ct = default) =>
await _context.DailyNotes.AsNoTracking()
.Where(n => n.Date == day)
.OrderBy(n => n.SortOrder)
.ToListAsync(ct);
public async Task<IReadOnlyList<DailyNoteEntity>> ListBetweenAsync(
DateOnly start, DateOnly end, CancellationToken ct = default) =>
await _context.DailyNotes.AsNoTracking()
.Where(n => n.Date >= start && n.Date <= end)
.OrderBy(n => n.Date).ThenBy(n => n.SortOrder)
.ToListAsync(ct);
public async Task<DailyNoteEntity> AddAsync(DateOnly day, string text, CancellationToken ct = default)
{
var nextOrder = await _context.DailyNotes
.Where(n => n.Date == day)
.Select(n => (int?)n.SortOrder)
.MaxAsync(ct) ?? -1;
var note = new DailyNoteEntity
{
Date = day,
Text = text,
SortOrder = nextOrder + 1,
CreatedAt = DateTime.UtcNow,
};
_context.DailyNotes.Add(note);
await _context.SaveChangesAsync(ct);
return note;
}
public async Task UpdateAsync(string id, string text, CancellationToken ct = default)
{
var row = await _context.DailyNotes.FirstOrDefaultAsync(n => n.Id == id, ct);
if (row is null) return;
row.Text = text;
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(string id, CancellationToken ct = default)
{
var row = await _context.DailyNotes.FirstOrDefaultAsync(n => n.Id == id, ct);
if (row is null) return;
_context.DailyNotes.Remove(row);
await _context.SaveChangesAsync(ct);
}
}
- Step 4: Run the test to verify it passes
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter DailyNoteRepositoryTests
Expected: PASS (3 tests).
- Step 5: Commit
git add src/ClaudeDo.Data/Repositories/DailyNoteRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/DailyNoteRepositoryTests.cs
git commit -m "feat(data): add DailyNoteRepository with tests"
Task 5: WeekReportRepository (TDD)
Files:
-
Create:
src/ClaudeDo.Data/Repositories/WeekReportRepository.cs -
Test:
tests/ClaudeDo.Worker.Tests/Repositories/WeekReportRepositoryTests.cs -
Step 1: Write the failing test
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Repositories;
public class WeekReportRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private static readonly DateOnly Start = new(2026, 5, 28);
private static readonly DateOnly End = new(2026, 6, 3);
[Fact]
public async Task Upsert_Insert_Then_GetByRange_RoundTrips()
{
using (var ctx = _db.CreateContext())
await new WeekReportRepository(ctx).UpsertAsync(Start, End, "# Report");
using var read = _db.CreateContext();
var row = await new WeekReportRepository(read).GetByRangeAsync(Start, End);
Assert.NotNull(row);
Assert.Equal("# Report", row!.Markdown);
}
[Fact]
public async Task Upsert_Existing_Overwrites_Markdown()
{
using (var ctx = _db.CreateContext())
await new WeekReportRepository(ctx).UpsertAsync(Start, End, "old");
using (var ctx = _db.CreateContext())
await new WeekReportRepository(ctx).UpsertAsync(Start, End, "new");
using var read = _db.CreateContext();
var row = await new WeekReportRepository(read).GetByRangeAsync(Start, End);
Assert.Equal("new", row!.Markdown);
}
[Fact]
public async Task GetByRange_MissingRange_ReturnsNull()
{
using var read = _db.CreateContext();
Assert.Null(await new WeekReportRepository(read).GetByRangeAsync(Start, End));
}
}
- Step 2: Run the test to verify it fails
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportRepositoryTests
Expected: FAIL — WeekReportRepository does not exist.
- Step 3: Implement
WeekReportRepository.cs
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class WeekReportRepository
{
private readonly ClaudeDoDbContext _context;
public WeekReportRepository(ClaudeDoDbContext context) => _context = context;
public async Task<WeekReportEntity?> GetByRangeAsync(
DateOnly start, DateOnly end, CancellationToken ct = default) =>
await _context.WeekReports.AsNoTracking()
.FirstOrDefaultAsync(r => r.StartDate == start && r.EndDate == end, ct);
public async Task UpsertAsync(DateOnly start, DateOnly end, string markdown, CancellationToken ct = default)
{
var row = await _context.WeekReports
.FirstOrDefaultAsync(r => r.StartDate == start && r.EndDate == end, ct);
if (row is null)
{
_context.WeekReports.Add(new WeekReportEntity
{
StartDate = start,
EndDate = end,
Markdown = markdown,
GeneratedAt = DateTime.UtcNow,
});
}
else
{
row.Markdown = markdown;
row.GeneratedAt = DateTime.UtcNow;
}
await _context.SaveChangesAsync(ct);
}
}
- Step 4: Run the test to verify it passes
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportRepositoryTests
Expected: PASS (3 tests).
- Step 5: Commit
git add src/ClaudeDo.Data/Repositories/WeekReportRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/WeekReportRepositoryTests.cs
git commit -m "feat(data): add WeekReportRepository with tests"
Phase 2 — Worker report pipeline
Task 6: Report models + reader interface
Files:
-
Create:
src/ClaudeDo.Worker/Report/ReportModels.cs -
Create:
src/ClaudeDo.Worker/Report/Interfaces/IClaudeHistoryReader.cs -
Step 1: Create
ReportModels.cs
namespace ClaudeDo.Worker.Report;
public sealed class DayActivity
{
public DateOnly Date { get; init; }
public List<string> Prompts { get; } = new();
public List<string> Summaries { get; } = new();
}
public sealed class RepoActivity
{
public required string RepoPath { get; init; }
public List<DayActivity> Days { get; } = new();
}
- Step 2: Create
Interfaces/IClaudeHistoryReader.cs
namespace ClaudeDo.Worker.Report.Interfaces;
public interface IClaudeHistoryReader
{
Task<IReadOnlyList<RepoActivity>> ReadAsync(
DateOnly start,
DateOnly end,
IReadOnlyList<string> excludedPrefixes,
CancellationToken ct = default);
}
- Step 3: Build
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
Expected: PASS.
- Step 4: Commit
git add src/ClaudeDo.Worker/Report/ReportModels.cs src/ClaudeDo.Worker/Report/Interfaces/IClaudeHistoryReader.cs
git commit -m "feat(worker): report activity models and reader interface"
Task 7: ClaudeHistoryReader (TDD)
Parses <root>/*/*.jsonl. Keeps user prompt text and the last assistant text per session file, grouped by cwd → date, filtered to [start, end] (inclusive, by the message timestamp's local date), dropping sessions/messages whose cwd starts with any excluded prefix. Malformed lines are skipped. Prompts containing <system-reminder> or empty text are skipped.
Files:
-
Create:
src/ClaudeDo.Worker/Report/ClaudeHistoryReader.cs -
Test:
tests/ClaudeDo.Worker.Tests/Report/ClaudeHistoryReaderTests.cs -
Step 1: Write the failing test
using ClaudeDo.Worker.Report;
namespace ClaudeDo.Worker.Tests.Report;
public class ClaudeHistoryReaderTests : IDisposable
{
private readonly string _root;
public ClaudeHistoryReaderTests()
{
_root = Path.Combine(Path.GetTempPath(), $"cdh_{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
public void Dispose() { try { Directory.Delete(_root, true); } catch { } }
private void WriteSession(string projectDir, string file, params string[] lines)
{
var dir = Path.Combine(_root, projectDir);
Directory.CreateDirectory(dir);
File.WriteAllLines(Path.Combine(dir, file), lines);
}
private static string UserLine(string cwd, string ts, string text) =>
$$"""{"type":"user","cwd":{{Json(cwd)}},"timestamp":"{{ts}}","message":{"role":"user","content":[{"type":"text","text":{{Json(text)}}}]}}""";
private static string AssistantLine(string cwd, string ts, string text) =>
$$"""{"type":"assistant","cwd":{{Json(cwd)}},"timestamp":"{{ts}}","message":{"role":"assistant","content":[{"type":"text","text":{{Json(text)}}}]}}""";
private static string Json(string s) => System.Text.Json.JsonSerializer.Serialize(s);
[Fact]
public async Task Extracts_Prompts_And_Last_Assistant_Summary_GroupedByRepoAndDay()
{
WriteSession("proj", "s1.jsonl",
UserLine(@"C:\Dev\Repos\App", "2026-06-01T08:00:00Z", "Add login"),
AssistantLine(@"C:\Dev\Repos\App", "2026-06-01T08:05:00Z", "first summary"),
AssistantLine(@"C:\Dev\Repos\App", "2026-06-01T08:30:00Z", "final summary"));
var reader = new ClaudeHistoryReader(_root);
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3),
Array.Empty<string>());
var repo = Assert.Single(result);
Assert.Equal(@"C:\Dev\Repos\App", repo.RepoPath);
var day = Assert.Single(repo.Days);
Assert.Equal(new[] { "Add login" }, day.Prompts);
Assert.Equal(new[] { "final summary" }, day.Summaries);
}
[Fact]
public async Task Drops_Sessions_Under_Excluded_Prefix_CaseInsensitive()
{
WriteSession("priv", "s.jsonl",
UserLine(@"C:\Private\Secret", "2026-06-01T08:00:00Z", "private work"));
var reader = new ClaudeHistoryReader(_root);
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3),
new[] { @"c:\private" });
Assert.Empty(result);
}
[Fact]
public async Task Filters_By_Date_Window_And_Skips_Noise_And_Malformed()
{
WriteSession("proj", "s.jsonl",
"this is not json",
UserLine(@"C:\Dev\App", "2026-05-01T08:00:00Z", "too old"),
UserLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "in range"),
UserLine(@"C:\Dev\App", "2026-06-02T09:00:00Z", "noise <system-reminder> blah"));
var reader = new ClaudeHistoryReader(_root);
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3),
Array.Empty<string>());
var day = Assert.Single(Assert.Single(result).Days);
Assert.Equal(new[] { "in range" }, day.Prompts);
}
}
- Step 2: Run the test to verify it fails
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter ClaudeHistoryReaderTests
Expected: FAIL — ClaudeHistoryReader does not exist.
- Step 3: Implement
ClaudeHistoryReader.cs
using System.Text.Json;
using ClaudeDo.Worker.Report.Interfaces;
namespace ClaudeDo.Worker.Report;
public sealed class ClaudeHistoryReader : IClaudeHistoryReader
{
private readonly string _projectsRoot;
public ClaudeHistoryReader(string projectsRoot) => _projectsRoot = projectsRoot;
public Task<IReadOnlyList<RepoActivity>> ReadAsync(
DateOnly start, DateOnly end, IReadOnlyList<string> excludedPrefixes, CancellationToken ct = default)
{
// (repoPath, date) -> day bucket
var buckets = new Dictionary<(string Repo, DateOnly Date), DayActivity>();
var normalizedExcludes = excludedPrefixes
.Select(NormalizePath).Where(p => p.Length > 0).ToArray();
if (Directory.Exists(_projectsRoot))
{
foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
ReadFile(file, start, end, normalizedExcludes, buckets);
}
}
var repos = buckets
.GroupBy(kv => kv.Key.Repo)
.Select(g =>
{
var ra = new RepoActivity { RepoPath = g.Key };
foreach (var day in g.OrderBy(kv => kv.Key.Date).Select(kv => kv.Value))
ra.Days.Add(day);
return ra;
})
.OrderBy(r => r.RepoPath)
.ToList();
return Task.FromResult<IReadOnlyList<RepoActivity>>(repos);
}
private static void ReadFile(
string file, DateOnly start, DateOnly end, string[] excludes,
Dictionary<(string, DateOnly), DayActivity> buckets)
{
// Track the last assistant text in this session file so we keep only the closing summary.
string? lastAssistantText = null;
string? lastAssistantRepo = null;
DateOnly lastAssistantDate = default;
foreach (var line in File.ReadLines(file))
{
if (string.IsNullOrWhiteSpace(line)) continue;
JsonDocument doc;
try { doc = JsonDocument.Parse(line); }
catch (JsonException) { continue; }
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) continue;
if (!root.TryGetProperty("type", out var typeEl)) continue;
var type = typeEl.GetString();
if (type is not ("user" or "assistant")) continue;
if (!root.TryGetProperty("cwd", out var cwdEl) || cwdEl.ValueKind != JsonValueKind.String) continue;
var cwd = cwdEl.GetString()!;
if (IsExcluded(cwd, excludes)) continue;
if (!root.TryGetProperty("timestamp", out var tsEl) ||
!DateTimeOffset.TryParse(tsEl.GetString(), out var ts)) continue;
var date = DateOnly.FromDateTime(ts.LocalDateTime);
if (date < start || date > end) continue;
var text = ExtractText(root);
if (string.IsNullOrWhiteSpace(text)) continue;
if (type == "user")
{
if (text.Contains("<system-reminder>", StringComparison.OrdinalIgnoreCase)) continue;
Bucket(buckets, cwd, date).Prompts.Add(text.Trim());
}
else // assistant — remember only the last
{
lastAssistantText = text.Trim();
lastAssistantRepo = cwd;
lastAssistantDate = date;
}
}
}
if (lastAssistantText is not null && lastAssistantRepo is not null)
Bucket(buckets, lastAssistantRepo, lastAssistantDate).Summaries.Add(lastAssistantText);
}
private static DayActivity Bucket(
Dictionary<(string, DateOnly), DayActivity> buckets, string repo, DateOnly date)
{
var key = (repo, date);
if (!buckets.TryGetValue(key, out var day))
{
day = new DayActivity { Date = date };
buckets[key] = day;
}
return day;
}
private static string ExtractText(JsonElement root)
{
if (!root.TryGetProperty("message", out var msg) ||
!msg.TryGetProperty("content", out var content)) return "";
if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? "";
if (content.ValueKind != JsonValueKind.Array) return "";
var parts = new List<string>();
foreach (var item in content.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
if (item.TryGetProperty("type", out var t) && t.GetString() == "text" &&
item.TryGetProperty("text", out var txt) && txt.ValueKind == JsonValueKind.String)
parts.Add(txt.GetString() ?? "");
}
return string.Join("\n", parts);
}
private static bool IsExcluded(string cwd, string[] excludes)
{
var norm = NormalizePath(cwd);
return excludes.Any(p => norm.StartsWith(p, StringComparison.Ordinal));
}
private static string NormalizePath(string p) =>
(p ?? "").Replace('/', '\\').TrimEnd('\\').ToLowerInvariant();
}
- Step 4: Run the test to verify it passes
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter ClaudeHistoryReaderTests
Expected: PASS (3 tests).
- Step 5: Commit
git add src/ClaudeDo.Worker/Report/ClaudeHistoryReader.cs tests/ClaudeDo.Worker.Tests/Report/ClaudeHistoryReaderTests.cs
git commit -m "feat(worker): ClaudeHistoryReader distills session logs"
Task 8: WeekReportPromptBuilder (TDD)
Pivots repo→day activity into day-major order and renders the prompt from the template in the spec. Pure function — no I/O.
Files:
-
Create:
src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs -
Test:
tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs -
Step 1: Write the failing test
using ClaudeDo.Worker.Report;
namespace ClaudeDo.Worker.Tests.Report;
public class WeekReportPromptBuilderTests
{
[Fact]
public void Build_IsDayMajor_AndIncludesNotesAndInstructions()
{
var repoA = new RepoActivity { RepoPath = @"C:\Dev\App" };
var d1 = new DayActivity { Date = new DateOnly(2026, 6, 1) };
d1.Prompts.Add("Add login");
d1.Summaries.Add("Implemented login");
repoA.Days.Add(d1);
var d2 = new DayActivity { Date = new DateOnly(2026, 6, 2) };
d2.Prompts.Add("Fix bug");
repoA.Days.Add(d2);
var notes = new Dictionary<DateOnly, List<string>>
{
[new DateOnly(2026, 6, 1)] = new() { "Standup um 9" },
};
var prompt = WeekReportPromptBuilder.Build(
new DateOnly(2026, 5, 28), new DateOnly(2026, 6, 3),
new[] { repoA }, notes);
Assert.Contains("Write the ENTIRE report in German", prompt);
// Day-major: 2026-06-01 section appears before 2026-06-02 section
var idxJun1 = prompt.IndexOf("2026-06-01", StringComparison.Ordinal);
var idxJun2 = prompt.IndexOf("2026-06-02", StringComparison.Ordinal);
Assert.True(idxJun1 >= 0 && idxJun2 > idxJun1);
Assert.Contains("Add login", prompt);
Assert.Contains("Implemented login", prompt);
Assert.Contains("Standup um 9", prompt);
Assert.Contains(@"C:\Dev\App", prompt);
}
}
- Step 2: Run the test to verify it fails
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportPromptBuilderTests
Expected: FAIL — WeekReportPromptBuilder does not exist.
- Step 3: Implement
WeekReportPromptBuilder.cs
using System.Globalization;
using System.Text;
namespace ClaudeDo.Worker.Report;
public static class WeekReportPromptBuilder
{
private const string Instructions = """
You are generating a concise weekly standup report for a software developer.
Summarize what they accomplished between {0} and {1}.
Rules:
- Write the ENTIRE report in German.
- Group by day. One "## {{Wochentag}}, {{dd.MM.yyyy}}" section per day that has
activity (German weekday names). Omit days with no activity entirely.
- Within each day: 3-5 first-person, past-tense bullets ("- Habe X umgesetzt",
"- Y behoben"). Merge related small work into one bullet.
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
- Blend the developer's own notes and the derived activity into ONE deduplicated
bullet list per day. The developer's notes are authoritative - never omit or
contradict their substance.
- Name the project/repo when it adds clarity.
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
""";
public static string Build(
DateOnly start, DateOnly end,
IReadOnlyList<RepoActivity> activity,
IReadOnlyDictionary<DateOnly, List<string>> notesByDay)
{
var sb = new StringBuilder();
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, Instructions,
start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)));
sb.AppendLine();
// Pivot repo->day into day-major.
var days = new SortedDictionary<DateOnly, List<(string Repo, DayActivity Day)>>();
foreach (var repo in activity)
foreach (var day in repo.Days)
{
if (!days.TryGetValue(day.Date, out var list))
days[day.Date] = list = new();
list.Add((repo.RepoPath, day));
}
foreach (var d in notesByDay.Keys)
if (!days.ContainsKey(d)) days[d] = new();
sb.AppendLine("== Activity (from session history) ==");
foreach (var (date, repos) in days)
{
sb.AppendLine($"### {date:yyyy-MM-dd}");
foreach (var (repoPath, day) in repos)
{
sb.AppendLine($"Repo: {repoPath}");
foreach (var p in day.Prompts) sb.AppendLine($" Prompt: {p}");
foreach (var s in day.Summaries) sb.AppendLine($" Summary: {s}");
}
sb.AppendLine();
}
sb.AppendLine("== Developer notes ==");
foreach (var (date, _) in days)
{
if (!notesByDay.TryGetValue(date, out var notes) || notes.Count == 0) continue;
sb.AppendLine($"### {date:yyyy-MM-dd}");
foreach (var n in notes) sb.AppendLine($" - {n}");
}
return sb.ToString();
}
}
- Step 4: Run the test to verify it passes
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportPromptBuilderTests
Expected: PASS.
- Step 5: Commit
git add src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs
git commit -m "feat(worker): week report prompt builder (day-major pivot)"
Task 9: WeekReportService (TDD)
Orchestrates: read settings → distill activity → load notes → empty-window short-circuit → build prompt → one-shot claude -p → store + return. Throws InvalidOperationException on a failed Claude run (the hub maps it to a client error) and does not store.
Files:
-
Create:
src/ClaudeDo.Worker/Report/Interfaces/IWeekReportService.cs -
Create:
src/ClaudeDo.Worker/Report/WeekReportService.cs -
Test:
tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs -
Step 1: Create
Interfaces/IWeekReportService.cs
namespace ClaudeDo.Worker.Report.Interfaces;
public interface IWeekReportService
{
Task<string?> GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default);
Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default);
}
- Step 2: Write the failing test
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
namespace ClaudeDo.Worker.Tests.Report;
public class WeekReportServiceTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private static readonly DateOnly Start = new(2026, 5, 28);
private static readonly DateOnly End = new(2026, 6, 3);
private sealed class FakeReader : IClaudeHistoryReader
{
public IReadOnlyList<RepoActivity> Result = Array.Empty<RepoActivity>();
public Task<IReadOnlyList<RepoActivity>> ReadAsync(
DateOnly s, DateOnly e, IReadOnlyList<string> ex, CancellationToken ct) => Task.FromResult(Result);
}
private sealed class FakeClaude : IClaudeProcess
{
public int Calls;
public RunResult Next = new() { ExitCode = 0, ResultMarkdown = "## Bericht" };
public Task<RunResult> RunAsync(string args, string prompt, string wd, Func<string, Task> onLine, CancellationToken ct)
{ Calls++; return Task.FromResult(Next); }
}
private WeekReportService Make(FakeReader reader, FakeClaude claude) =>
new(reader, _db.CreateFactory(), claude, NullLogger<WeekReportService>.Instance);
[Fact]
public async Task EmptyWindow_ProducesNoActivityReport_WithoutCallingClaude()
{
var claude = new FakeClaude();
var svc = Make(new FakeReader(), claude);
var md = await svc.GenerateAsync(Start, End);
Assert.Equal(0, claude.Calls);
Assert.Contains("Keine Aktivität", md);
using var ctx = _db.CreateContext();
Assert.NotNull(await new WeekReportRepository(ctx).GetByRangeAsync(Start, End));
}
[Fact]
public async Task SuccessPath_StoresAndReturnsClaudeMarkdown()
{
var repo = new RepoActivity { RepoPath = @"C:\Dev\App" };
var day = new DayActivity { Date = new DateOnly(2026, 6, 1) };
day.Prompts.Add("Add login");
repo.Days.Add(day);
var claude = new FakeClaude { Next = new RunResult { ExitCode = 0, ResultMarkdown = "## Bericht\n- Habe Login umgesetzt" } };
var svc = Make(new FakeReader { Result = new[] { repo } }, claude);
var md = await svc.GenerateAsync(Start, End);
Assert.Equal(1, claude.Calls);
Assert.Contains("Habe Login umgesetzt", md);
Assert.Equal(md, await svc.GetStoredAsync(Start, End));
}
[Fact]
public async Task ClaudeFailure_Throws_AndDoesNotStore()
{
var repo = new RepoActivity { RepoPath = @"C:\Dev\App" };
var day = new DayActivity { Date = new DateOnly(2026, 6, 1) };
day.Prompts.Add("x");
repo.Days.Add(day);
var claude = new FakeClaude { Next = new RunResult { ExitCode = 1, ErrorMarkdown = "boom" } };
var svc = Make(new FakeReader { Result = new[] { repo } }, claude);
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.GenerateAsync(Start, End));
Assert.Null(await svc.GetStoredAsync(Start, End));
}
}
- Step 3: Run the test to verify it fails
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportServiceTests
Expected: FAIL — WeekReportService does not exist.
- Step 4: Implement
WeekReportService.cs
using System.Text.Json;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Report;
public sealed class WeekReportService : IWeekReportService
{
private static readonly string[] DefaultExcludes = { @"C:\Private" };
private const string NoActivity = "_Keine Aktivität in diesem Zeitraum._";
private readonly IClaudeHistoryReader _reader;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IClaudeProcess _claude;
private readonly ILogger<WeekReportService> _logger;
public WeekReportService(
IClaudeHistoryReader reader,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IClaudeProcess claude,
ILogger<WeekReportService> logger)
{
_reader = reader;
_dbFactory = dbFactory;
_claude = claude;
_logger = logger;
}
public async Task<string?> GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var row = await new WeekReportRepository(ctx).GetByRangeAsync(start, end, ct);
return row?.Markdown;
}
public async Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
{
string[] excludes;
string model;
IReadOnlyList<Data.Models.DailyNoteEntity> noteRows;
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
{
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
excludes = ParseExcludes(settings.ReportExcludedPaths);
model = string.IsNullOrWhiteSpace(settings.DefaultModel) ? "sonnet" : settings.DefaultModel;
noteRows = await new DailyNoteRepository(ctx).ListBetweenAsync(start, end, ct);
}
var activity = await _reader.ReadAsync(start, end, excludes, ct);
var notesByDay = noteRows
.GroupBy(n => n.Date)
.ToDictionary(g => g.Key, g => g.Select(n => n.Text).ToList());
string markdown;
var hasActivity = activity.Any(r => r.Days.Any(d => d.Prompts.Count > 0 || d.Summaries.Count > 0));
if (!hasActivity && notesByDay.Count == 0)
{
markdown = NoActivity;
}
else
{
var prompt = WeekReportPromptBuilder.Build(start, end, activity, notesByDay);
var args = $"-p --output-format stream-json --verbose --permission-mode auto --model {model}";
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
if (!result.IsSuccess)
throw new InvalidOperationException(result.ErrorMarkdown ?? "Claude konnte den Bericht nicht erzeugen.");
markdown = result.ResultMarkdown!;
}
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
await new WeekReportRepository(ctx).UpsertAsync(start, end, markdown, ct);
return markdown;
}
private static string[] ParseExcludes(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return DefaultExcludes;
try
{
var list = JsonSerializer.Deserialize<List<string>>(json);
return list is { Count: > 0 } ? list.ToArray() : DefaultExcludes;
}
catch (JsonException) { return DefaultExcludes; }
}
}
- Step 5: Run the test to verify it passes
Run: dotnet test tests/ClaudeDo.Worker.Tests --filter WeekReportServiceTests
Expected: PASS (3 tests).
- Step 6: Commit
git add src/ClaudeDo.Worker/Report/Interfaces/IWeekReportService.cs src/ClaudeDo.Worker/Report/WeekReportService.cs tests/ClaudeDo.Worker.Tests/Report/WeekReportServiceTests.cs
git commit -m "feat(worker): WeekReportService orchestrates generate + store"
Phase 3 — IPC wiring (DI, hub, client)
These are wiring tasks. They are verified by build + the manual smoke test in Phase 7 (hub method tests need live-hub infrastructure that is out of scope here).
Task 10: Register report services in DI
Files:
-
Modify:
src/ClaudeDo.Worker/Program.cs(after line 52, near the other singletons) -
Step 1: Add registrations
Add after the TaskRunner/Prime singletons block (e.g. after line 63), and add the using if missing (using ClaudeDo.Worker.Report; / using ClaudeDo.Worker.Report.Interfaces;):
builder.Services.AddSingleton<IClaudeHistoryReader>(_ =>
new ClaudeHistoryReader(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects")));
builder.Services.AddSingleton<IWeekReportService, WeekReportService>();
- Step 2: Build
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
Expected: PASS.
- Step 3: Commit
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): register report reader and service in DI"
Task 11: Hub methods + DailyNoteDto (Worker side)
Files:
-
Create:
src/ClaudeDo.Worker/Report/DailyNoteDto.cs -
Modify:
src/ClaudeDo.Worker/Hub/WorkerHub.cs(constructor + new methods) -
Step 1: Create
DailyNoteDto.cs
namespace ClaudeDo.Worker.Report;
public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder);
- Step 2: Inject
IWeekReportServiceintoWorkerHub
In WorkerHub.cs: add field private readonly IWeekReportService _report;, add constructor parameter IWeekReportService report, (e.g. after ITaskStateService state), and assign _report = report;. Add using ClaudeDo.Worker.Report; and using ClaudeDo.Worker.Report.Interfaces; and using ClaudeDo.Data.Repositories; (if not present) and using System.Globalization;.
- Step 3: Add the hub methods (before the closing brace of
WorkerHub)
private static DateOnly Day(string iso) => DateOnly.ParseExact(iso, "yyyy-MM-dd", CultureInfo.InvariantCulture);
public Task<string?> GetWeekReport(string startIso, string endIso) =>
_report.GetStoredAsync(Day(startIso), Day(endIso));
public Task<string> GenerateWeekReport(string startIso, string endIso) =>
HubGuard(() => _report.GenerateAsync(Day(startIso), Day(endIso)), "report generation failed");
public async Task<List<DailyNoteDto>> GetDailyNotes(string dayIso)
{
using var ctx = _dbFactory.CreateDbContext();
var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso));
return notes.Select(n => new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder)).ToList();
}
public async Task<DailyNoteDto> AddDailyNote(string dayIso, string text)
{
using var ctx = _dbFactory.CreateDbContext();
var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text);
return new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder);
}
public async Task UpdateDailyNote(string id, string text)
{
using var ctx = _dbFactory.CreateDbContext();
await new DailyNoteRepository(ctx).UpdateAsync(id, text);
}
public async Task DeleteDailyNote(string id)
{
using var ctx = _dbFactory.CreateDbContext();
await new DailyNoteRepository(ctx).DeleteAsync(id);
}
- Step 4: Build
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
Expected: PASS.
- Step 5: Commit
git add src/ClaudeDo.Worker/Report/DailyNoteDto.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): hub methods for week report and daily notes"
Task 12: WorkerClient methods + UI DailyNoteDto
Files:
-
Create:
src/ClaudeDo.Ui/Services/ReportDtos.cs -
Modify:
src/ClaudeDo.Ui/Services/WorkerClient.cs -
Modify:
src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs -
Step 1: Create
ReportDtos.cs
namespace ClaudeDo.Ui.Services;
public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder);
- Step 2: Add methods to
WorkerClient.cs
Add near the other invoke methods (e.g. after the Prime methods around line 325). _hub and TryInvokeAsync<T> already exist:
private static string IsoDay(DateOnly d) => d.ToString("yyyy-MM-dd");
public Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end)
=> TryInvokeAsync<string>("GetWeekReport", IsoDay(start), IsoDay(end));
public Task<string?> GenerateWeekReportAsync(DateOnly start, DateOnly end)
=> TryInvokeAsync<string>("GenerateWeekReport", IsoDay(start), IsoDay(end));
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
public Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
=> TryInvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text);
public async Task UpdateDailyNoteAsync(string id, string text)
=> await _hub.InvokeAsync("UpdateDailyNote", id, text);
public async Task DeleteDailyNoteAsync(string id)
=> await _hub.InvokeAsync("DeleteDailyNote", id);
Note: GenerateWeekReportAsync uses TryInvokeAsync so a hub-side failure (mapped to HubException) surfaces as a thrown exception the modal can catch — confirm TryInvokeAsync rethrows HubException rather than swallowing it; if it swallows, call _hub.InvokeAsync<string>("GenerateWeekReport", ...) directly instead.
- Step 2b: Declare the same six methods on
IWorkerClient
Add to src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
Task<string?> GenerateWeekReportAsync(DateOnly start, DateOnly end);
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
Task UpdateDailyNoteAsync(string id, string text);
Task DeleteDailyNoteAsync(string id);
- Step 3: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 4: Commit
git add src/ClaudeDo.Ui/Services/ReportDtos.cs src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): WorkerClient methods for week report and daily notes"
Phase 4 — Settings (excluded paths + standup weekday)
AppSettingsDto is a positional record constructed in two call sites (hub UpdateAppSettings, UI SettingsModalViewModel.Save) and read in two (hub GetAppSettings, UI LoadAsync). All four must change together. The two new fields go at the END of the record to minimize disruption.
Task 13: Persist the two settings columns end-to-end
Files:
-
Modify:
src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs:38-55(UpdateAsync) -
Modify:
src/ClaudeDo.Worker/Hub/WorkerHub.cs:20-29(DTO),:201-210(Get),:217-229(Update) -
Modify:
src/ClaudeDo.Ui/Services/WorkerClient.cs:468(DTO) -
Step 1: Persist new fields in
AppSettingsRepository.UpdateAsync
Add inside UpdateAsync, before await _context.SaveChangesAsync(ct); (line 54):
row.ReportExcludedPaths = string.IsNullOrWhiteSpace(updated.ReportExcludedPaths)
? null : updated.ReportExcludedPaths;
row.StandupWeekday = updated.StandupWeekday;
- Step 2: Extend the Worker
AppSettingsDto
In WorkerHub.cs change the record (lines 20-29) to add two trailing fields:
public record AppSettingsDto(
string DefaultClaudeInstructions,
string DefaultModel,
int DefaultMaxTurns,
string DefaultPermissionMode,
int MaxParallelExecutions,
string WorktreeStrategy,
string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled,
int WorktreeAutoCleanupDays,
string? ReportExcludedPaths,
int StandupWeekday);
- Step 3: Map them in
GetAppSettings
In GetAppSettings (line 201), add two trailing constructor args:
row.WorktreeAutoCleanupDays,
row.ReportExcludedPaths,
row.StandupWeekday);
- Step 4: Map them in
UpdateAppSettings
In UpdateAppSettings (line 217 entity initializer), add:
WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays,
ReportExcludedPaths = dto.ReportExcludedPaths,
StandupWeekday = dto.StandupWeekday == 0 ? (int)DayOfWeek.Wednesday : dto.StandupWeekday,
(replace the existing WorktreeAutoCleanupDays = ..., line, which currently ends the initializer).
- Step 5: Extend the UI
AppSettingsDto(WorkerClient.cs:468)
Add the same two trailing fields (string? ReportExcludedPaths, int StandupWeekday) to the UI record so the SignalR shapes match.
- Step 6: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj && dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
Expected: FAIL in SettingsModalViewModel (DTO now needs 11 args) — fixed in Task 14.
- Step 7: Commit (after Task 14 build passes — see Task 14 Step 5)
Task 14: Settings tab fields
Files:
-
Modify:
src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs -
Modify:
src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs:41-49(load),:68-77(save) -
Modify: the General settings tab view (
src/ClaudeDo.Ui/Views/Modals/...— the view bound toGeneralSettingsTabViewModel; locate via itsx:DataType) -
Step 1: Add observable properties to
GeneralSettingsTabViewModel
// Newline-separated path prefixes excluded from the weekly report.
[ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
// 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
[ObservableProperty] private int _standupWeekday = (int)DayOfWeek.Wednesday;
- Step 2: Load them in
SettingsModalViewModel.LoadAsync
Inside the if (dto is not null) block (after line 45):
General.ReportExcludedPaths = string.IsNullOrWhiteSpace(dto.ReportExcludedPaths)
? @"C:\Private"
: string.Join(Environment.NewLine,
System.Text.Json.JsonSerializer.Deserialize<List<string>>(dto.ReportExcludedPaths) ?? new());
General.StandupWeekday = dto.StandupWeekday == 0 ? (int)DayOfWeek.Wednesday : dto.StandupWeekday;
- Step 3: Save them in
SettingsModalViewModel.Save
Change the new AppSettingsDto(...) (lines 68-77) to append the two new args:
Worktrees.WorktreeAutoCleanupDays,
System.Text.Json.JsonSerializer.Serialize(
General.ReportExcludedPaths
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
General.StandupWeekday);
- Step 4: Add the UI to the General settings tab view
In the General tab view's layout, add (German labels, matching nearby control styles):
<TextBlock Classes="meta" Text="Bericht: ausgeschlossene Pfade (einer pro Zeile)"/>
<TextBox AcceptsReturn="True" MinHeight="60"
Text="{Binding ReportExcludedPaths}"/>
<TextBlock Classes="meta" Text="Standup-Wochentag"/>
<ComboBox SelectedIndex="{Binding StandupWeekday}">
<ComboBoxItem>Sonntag</ComboBoxItem><ComboBoxItem>Montag</ComboBoxItem>
<ComboBoxItem>Dienstag</ComboBoxItem><ComboBoxItem>Mittwoch</ComboBoxItem>
<ComboBoxItem>Donnerstag</ComboBoxItem><ComboBoxItem>Freitag</ComboBoxItem>
<ComboBoxItem>Samstag</ComboBoxItem>
</ComboBox>
(SelectedIndex maps directly to System.DayOfWeek 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.
git add src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/Modals/
git commit -m "feat(settings): persist report excluded paths and standup weekday"
Phase 5 — Weekly Report modal
Task 15: WeeklyReportModalViewModel (+ default-range unit test)
Files:
-
Create:
src/ClaudeDo.Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs -
Test:
tests/ClaudeDo.Ui.Tests/ViewModels/WeeklyReportRangeTests.cs -
Step 1: Write the failing test for the default range
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class WeeklyReportRangeTests
{
[Fact]
public void DefaultRange_TodayIsStandupDay_GoesBackToPreviousStandup()
{
// Wednesday 2026-06-03; previous Wednesday is 2026-05-27
var (start, end) = WeeklyReportModalViewModel.DefaultRange(
DayOfWeek.Wednesday, new DateOnly(2026, 6, 3));
Assert.Equal(new DateOnly(2026, 5, 27), start);
Assert.Equal(new DateOnly(2026, 6, 3), end);
}
[Fact]
public void DefaultRange_MidWeek_StartsAtMostRecentStandup()
{
// Friday 2026-06-05; most recent Wednesday is 2026-06-03
var (start, end) = WeeklyReportModalViewModel.DefaultRange(
DayOfWeek.Wednesday, new DateOnly(2026, 6, 5));
Assert.Equal(new DateOnly(2026, 6, 3), start);
Assert.Equal(new DateOnly(2026, 6, 5), end);
}
}
- Step 2: Run the test to verify it fails
Run: dotnet test tests/ClaudeDo.Ui.Tests --filter WeeklyReportRangeTests
Expected: FAIL — type does not exist.
- Step 3: Implement
WeeklyReportModalViewModel.cs
using ClaudeDo.Ui.Services.Interfaces;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class WeeklyReportModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
public WeeklyReportModalViewModel(IWorkerClient worker) => _worker = worker;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasReport))]
[NotifyPropertyChangedFor(nameof(EmptyStateVisible))]
private string? _reportMarkdown;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EmptyStateVisible))]
[NotifyCanExecuteChangedFor(nameof(GenerateCommand))]
private bool _isBusy;
[ObservableProperty] private DateTime? _startDate;
[ObservableProperty] private DateTime? _endDate;
[ObservableProperty] private string _statusMessage = "";
public bool HasReport => !string.IsNullOrWhiteSpace(ReportMarkdown);
public bool EmptyStateVisible => !HasReport && !IsBusy;
public Action? CloseAction { get; set; }
[RelayCommand] private void Close() => CloseAction?.Invoke();
/// <summary>Range from the most recent standup weekday up to today (inclusive).
/// If today IS the standup day, go back to the previous standup.</summary>
public static (DateOnly Start, DateOnly End) DefaultRange(DayOfWeek standup, DateOnly today)
{
int diff = ((int)today.DayOfWeek - (int)standup + 7) % 7;
if (diff == 0) diff = 7;
return (today.AddDays(-diff), today);
}
public async Task InitializeAsync()
{
var standup = DayOfWeek.Wednesday;
var settings = await _worker.GetAppSettingsAsync();
if (settings is not null && settings.StandupWeekday is >= 0 and <= 6)
standup = (DayOfWeek)settings.StandupWeekday;
var (start, end) = DefaultRange(standup, DateOnly.FromDateTime(DateTime.Today));
StartDate = start.ToDateTime(TimeOnly.MinValue);
EndDate = end.ToDateTime(TimeOnly.MinValue);
await LoadStoredAsync();
}
partial void OnStartDateChanged(DateTime? value) => _ = LoadStoredAsync();
partial void OnEndDateChanged(DateTime? value) => _ = LoadStoredAsync();
private bool RangeValid => StartDate is not null && EndDate is not null && StartDate <= EndDate;
private async Task LoadStoredAsync()
{
if (!RangeValid) return;
StatusMessage = "";
try
{
ReportMarkdown = await _worker.GetWeekReportAsync(
DateOnly.FromDateTime(StartDate!.Value), DateOnly.FromDateTime(EndDate!.Value));
}
catch (Exception ex) { StatusMessage = ex.Message; }
}
private bool CanGenerate() => !IsBusy;
[RelayCommand(CanExecute = nameof(CanGenerate))]
private async Task Generate()
{
if (!RangeValid) { StatusMessage = "Ungültiger Zeitraum."; return; }
IsBusy = true;
StatusMessage = "Bericht wird erstellt…";
try
{
ReportMarkdown = await _worker.GenerateWeekReportAsync(
DateOnly.FromDateTime(StartDate!.Value), DateOnly.FromDateTime(EndDate!.Value));
StatusMessage = "";
}
catch (Exception ex) { StatusMessage = $"Fehler: {ex.Message}"; }
finally { IsBusy = false; }
}
}
- Step 4: Run the test to verify it passes
Run: dotnet test tests/ClaudeDo.Ui.Tests --filter WeeklyReportRangeTests
Expected: PASS (2 tests).
- Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WeeklyReportRangeTests.cs
git commit -m "feat(ui): WeeklyReportModalViewModel with default-range logic"
Task 16: WeeklyReportModalView
Files:
-
Create:
src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml -
Create:
src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml.cs -
Step 1: Create
WeeklyReportModalView.axaml(mirrorsAboutModalViewshell pattern)
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Modals.WeeklyReportModalView"
x:DataType="vm:WeeklyReportModalViewModel"
Title="Wochenbericht"
Width="820" Height="640"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="WOCHENBERICHT" CloseCommand="{Binding CloseCommand}">
<DockPanel Margin="20,16">
<!-- Toolbar: range + actions -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8">
<TextBlock Classes="meta" Text="Von" VerticalAlignment="Center"/>
<ctl:ThemedDatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Classes="meta" Text="Bis" VerticalAlignment="Center"/>
<ctl:ThemedDatePicker SelectedDate="{Binding EndDate}"/>
<Button Classes="btn" Content="Erstellen" Command="{Binding GenerateCommand}"
IsVisible="{Binding EmptyStateVisible}"/>
<Button Classes="btn" Content="Neu erstellen" Command="{Binding GenerateCommand}"
IsVisible="{Binding HasReport}"/>
</StackPanel>
<TextBlock DockPanel.Dock="Top" Classes="meta" Margin="0,8,0,0"
Text="{Binding StatusMessage}"/>
<!-- Empty state -->
<TextBlock DockPanel.Dock="Top" Classes="meta" Margin="0,16"
Text="Noch kein Bericht für diesen Zeitraum. Klicke „Erstellen“."
IsVisible="{Binding EmptyStateVisible}"/>
<!-- Report body -->
<ScrollViewer IsVisible="{Binding HasReport}">
<ctl:MarkdownView Markdown="{Binding ReportMarkdown}"/>
</ScrollViewer>
</DockPanel>
</ctl:ModalShell>
</Window>
- Step 2: Create
WeeklyReportModalView.axaml.cs
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace ClaudeDo.Ui.Views.Modals;
public partial class WeeklyReportModalView : Window
{
public WeeklyReportModalView() => InitializeComponent();
}
- Step 3: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 4: Commit
git add src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml src/ClaudeDo.Ui/Views/Modals/WeeklyReportModalView.axaml.cs
git commit -m "feat(ui): WeeklyReportModalView"
Task 17: Wire the modal (DI + shell command + menu + host)
Files:
-
Modify:
src/ClaudeDo.App/Program.cs:100-101(DI, mirror WorktreesOverview) -
Modify:
src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs(factory field, Show hook, open command) -
Modify:
src/ClaudeDo.Ui/Views/MainWindow.axaml.cs(assignShowWeeklyReportModal) -
Modify:
src/ClaudeDo.Ui/Views/MainWindow.axaml(Help menu item) -
Step 1: Register the VM + factory in
App/Program.cs(after line 101)
sc.AddTransient<WeeklyReportModalViewModel>();
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
- Step 2: Add factory + hook + command to
IslandsShellViewModel
Mirror the WorktreesOverview members. Add fields/props:
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
public Func<WeeklyReportModalViewModel, Task>? ShowWeeklyReportModal { get; set; }
Add the constructor parameter Func<WeeklyReportModalViewModel> weeklyReportVmFactory and assign _weeklyReportVmFactory = weeklyReportVmFactory;. Then the command:
private bool _weeklyReportOpen;
[RelayCommand]
private async Task OpenWeeklyReport()
{
if (ShowWeeklyReportModal is null || _weeklyReportOpen) return;
_weeklyReportOpen = true;
try
{
var vm = _weeklyReportVmFactory();
await vm.InitializeAsync();
await ShowWeeklyReportModal(vm);
}
finally { _weeklyReportOpen = false; }
}
- Step 3: Assign
ShowWeeklyReportModalinMainWindow.axaml.cs(next toShowWorktreesOverviewModal, ~line 55)
vm.ShowWeeklyReportModal = async (modal) =>
{
var dlg = new WeeklyReportModalView { DataContext = modal };
modal.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
- Step 4: Add a Help-menu item in
MainWindow.axaml(next to the Worktrees Overview item, ~line 67)
<MenuItem Header="Wochenbericht…" Command="{Binding OpenWeeklyReportCommand}"/>
- Step 5: Build and smoke-launch
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS. (Full manual check in Phase 7.)
- Step 6: Commit
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): open Weekly Report modal from the menu"
Phase 6 — Notes in My Day
Mirrors the existing IPrimeScheduleApi/WorkerPrimeScheduleApi pattern: a thin INotesApi keeps NotesEditorViewModel testable without faking all of IWorkerClient.
Task 18: INotesApi + WorkerNotesApi + DI
Files:
-
Create:
src/ClaudeDo.Ui/Services/Interfaces/INotesApi.cs -
Create:
src/ClaudeDo.Ui/Services/WorkerNotesApi.cs -
Modify:
src/ClaudeDo.App/Program.cs(register, nearWorkerPrimeScheduleApi) -
Step 1: Create
INotesApi.cs
namespace ClaudeDo.Ui.Services.Interfaces;
public interface INotesApi
{
Task<List<DailyNoteDto>> ListAsync(DateOnly day);
Task<DailyNoteDto?> AddAsync(DateOnly day, string text);
Task UpdateAsync(string id, string text);
Task DeleteAsync(string id);
}
- Step 2: Create
WorkerNotesApi.cs
using ClaudeDo.Ui.Services.Interfaces;
namespace ClaudeDo.Ui.Services;
public sealed class WorkerNotesApi : INotesApi
{
private readonly WorkerClient _client;
public WorkerNotesApi(WorkerClient client) => _client = client;
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);
public Task DeleteAsync(string id) => _client.DeleteDailyNoteAsync(id);
}
- Step 3: Register in
App/Program.cs(find whereWorkerPrimeScheduleApi/IPrimeScheduleApiis registered and add alongside)
sc.AddSingleton<INotesApi, WorkerNotesApi>();
- Step 4: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 5: Commit
git add src/ClaudeDo.Ui/Services/Interfaces/INotesApi.cs src/ClaudeDo.Ui/Services/WorkerNotesApi.cs src/ClaudeDo.App/Program.cs
git commit -m "feat(ui): INotesApi wrapper for daily notes"
Task 19: NotesEditorViewModel + NoteBulletViewModel (TDD)
Files:
-
Create:
src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs -
Test:
tests/ClaudeDo.Ui.Tests/ViewModels/NotesEditorViewModelTests.cs -
Step 1: Write the failing test
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class NotesEditorViewModelTests
{
private sealed class FakeNotes : INotesApi
{
public readonly List<DailyNoteDto> Store = new();
private int _seq;
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
Task.FromResult(Store.Where(n => n.Date == day.ToString("yyyy-MM-dd")).ToList());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text)
{
var dto = new DailyNoteDto($"id{_seq++}", day.ToString("yyyy-MM-dd"), text, Store.Count);
Store.Add(dto);
return Task.FromResult<DailyNoteDto?>(dto);
}
public Task UpdateAsync(string id, string text)
{
var i = Store.FindIndex(n => n.Id == id);
if (i >= 0) Store[i] = Store[i] with { Text = text };
return Task.CompletedTask;
}
public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; }
}
[Fact]
public async Task AddBullet_PersistsAndAppears_ForCurrentDay()
{
var api = new FakeNotes();
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
vm.NewBulletText = "Standup vorbereitet";
await vm.AddBulletCommand.ExecuteAsync(null);
Assert.Single(vm.Bullets);
Assert.Equal("Standup vorbereitet", vm.Bullets[0].Text);
Assert.Equal("", vm.NewBulletText);
Assert.Single(api.Store);
}
[Fact]
public async Task PrevAndNextDay_NavigateAndReload()
{
var api = new FakeNotes();
await api.AddAsync(new DateOnly(2026, 5, 31), "gestern");
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
Assert.Empty(vm.Bullets);
await vm.PrevDayCommand.ExecuteAsync(null);
Assert.Equal(new DateOnly(2026, 5, 31), vm.CurrentDay);
Assert.Single(vm.Bullets);
await vm.NextDayCommand.ExecuteAsync(null);
Assert.Equal(new DateOnly(2026, 6, 1), vm.CurrentDay);
Assert.Empty(vm.Bullets);
}
[Fact]
public async Task DeleteBullet_RemovesFromStoreAndList()
{
var api = new FakeNotes();
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
vm.NewBulletText = "weg damit";
await vm.AddBulletCommand.ExecuteAsync(null);
await vm.Bullets[0].DeleteCommand.ExecuteAsync(null);
Assert.Empty(vm.Bullets);
Assert.Empty(api.Store);
}
}
- Step 2: Run the test to verify it fails
Run: dotnet test tests/ClaudeDo.Ui.Tests --filter NotesEditorViewModelTests
Expected: FAIL — types do not exist.
- Step 3: Implement
NotesEditorViewModel.cs(contains both VMs)
using System.Collections.ObjectModel;
using ClaudeDo.Ui.Services.Interfaces;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class NoteBulletViewModel : ViewModelBase
{
private readonly Func<NoteBulletViewModel, Task> _save;
private readonly Func<NoteBulletViewModel, Task> _delete;
public string Id { get; }
[ObservableProperty] private string _text;
public NoteBulletViewModel(string id, string text,
Func<NoteBulletViewModel, Task> save, Func<NoteBulletViewModel, Task> delete)
{
Id = id;
_text = text;
_save = save;
_delete = delete;
}
[RelayCommand] private Task Save() => _save(this);
[RelayCommand] private Task Delete() => _delete(this);
}
public sealed partial class NotesEditorViewModel : ViewModelBase
{
private readonly INotesApi _api;
public NotesEditorViewModel(INotesApi api) => _api = api;
public ObservableCollection<NoteBulletViewModel> Bullets { get; } = new();
[ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private string _newBulletText = "";
// For the date picker (two-way).
public DateTime CurrentDate
{
get => CurrentDay.ToDateTime(TimeOnly.MinValue);
set { var d = DateOnly.FromDateTime(value); if (d != CurrentDay) _ = LoadDayAsync(d); }
}
public string CurrentDayLabel => CurrentDay.ToString("dddd, dd.MM.yyyy");
public async Task LoadDayAsync(DateOnly day)
{
CurrentDay = day;
OnPropertyChanged(nameof(CurrentDate));
OnPropertyChanged(nameof(CurrentDayLabel));
Bullets.Clear();
foreach (var dto in await _api.ListAsync(day))
Bullets.Add(MakeBullet(dto.Id, dto.Text));
}
private NoteBulletViewModel MakeBullet(string id, string text) =>
new(id, text, SaveBulletAsync, DeleteBulletAsync);
[RelayCommand]
private async Task AddBullet()
{
var text = NewBulletText.Trim();
if (text.Length == 0) return;
var dto = await _api.AddAsync(CurrentDay, text);
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text));
NewBulletText = "";
}
[RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1));
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
private Task SaveBulletAsync(NoteBulletViewModel b) => _api.UpdateAsync(b.Id, b.Text);
private async Task DeleteBulletAsync(NoteBulletViewModel b)
{
await _api.DeleteAsync(b.Id);
Bullets.Remove(b);
}
}
- Step 4: Run the test to verify it passes
Run: dotnet test tests/ClaudeDo.Ui.Tests --filter NotesEditorViewModelTests
Expected: PASS (3 tests).
- Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Islands/NotesEditorViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/NotesEditorViewModelTests.cs
git commit -m "feat(ui): NotesEditorViewModel with day navigation and bullet CRUD"
Task 20: NotesEditorView
Files:
-
Create:
src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml -
Create:
src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml.cs -
Step 1: Create
NotesEditorView.axaml(a UserControl hosted by the Details island)
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Islands.NotesEditorView"
x:DataType="vm:NotesEditorViewModel">
<DockPanel Margin="16">
<!-- Day navigator -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="‹" Command="{Binding PrevDayCommand}"/>
<ctl:ThemedDatePicker SelectedDate="{Binding CurrentDate, Mode=TwoWay}"/>
<Button Classes="btn" Content="›" Command="{Binding NextDayCommand}"/>
<Button Classes="btn" Content="Heute" Command="{Binding TodayCommand}"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding CurrentDayLabel}"/>
</StackPanel>
<!-- Add bullet -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,12,0,8">
<TextBox Width="420" Watermark="Neue Notiz…" Text="{Binding NewBulletText}"/>
<Button Classes="btn" Content="Hinzufügen" Command="{Binding AddBulletCommand}"/>
</StackPanel>
<!-- Bullets -->
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Bullets}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:NoteBulletViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2" ColumnSpacing="6">
<TextBox Grid.Column="0" Text="{Binding Text}"/>
<Button Grid.Column="1" Classes="btn" Content="Speichern" Command="{Binding SaveCommand}"/>
<Button Grid.Column="2" Classes="btn" Content="Löschen" Command="{Binding DeleteCommand}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</UserControl>
- Step 2: Create
NotesEditorView.axaml.cs
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.Islands;
public partial class NotesEditorView : UserControl
{
public NotesEditorView() => InitializeComponent();
}
- Step 3: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 4: Commit
git add src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml src/ClaudeDo.Ui/Views/Islands/NotesEditorView.axaml.cs
git commit -m "feat(ui): NotesEditorView"
Task 21: Details island notes mode
DetailsIslandViewModel hosts a NotesEditorViewModel and an IsNotesMode flag. Bind(row) turns notes mode OFF; ShowNotes() turns it ON and clears the bound task. The view shows the task panel or the notes editor based on the flag.
Files:
-
Modify:
src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs(constructor + flag + ShowNotes + Bind) -
Modify:
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml(host NotesEditorView) -
Modify:
src/ClaudeDo.App/Program.cs(ensureDetailsIslandViewModelcan resolveINotesApi) -
Step 1: Add notes-mode members to
DetailsIslandViewModel
Add an INotesApi constructor parameter (store as _notesApi) and:
[ObservableProperty] private bool _isNotesMode;
public NotesEditorViewModel Notes { get; }
In the constructor, after assigning dependencies:
Notes = new NotesEditorViewModel(_notesApi);
Add the entry method:
public void ShowNotes()
{
Bind(null); // clears the task panel/log
IsNotesMode = true;
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
}
- Step 2: Turn notes mode OFF inside
Bind
At the top of Bind (line 434, right after entering), add:
IsNotesMode = false;
- Step 3: Verify DI for the new constructor param
DetailsIslandViewModel is resolved by the container. Confirm INotesApi is registered (Task 18) so the new parameter injects. Build will fail if not.
- Step 4: Host the notes editor in
DetailsIslandView.axaml
Wrap the existing details content so it is visible only when NOT in notes mode (e.g. add IsVisible="{Binding !IsNotesMode}" to the root content panel), and add a sibling notes editor (add xmlns:isl="using:ClaudeDo.Ui.Views.Islands"):
<isl:NotesEditorView DataContext="{Binding Notes}"
IsVisible="{Binding $parent[UserControl].((vm:DetailsIslandViewModel)DataContext).IsNotesMode}"/>
(Place it as a sibling of the task content in the island's root layout. If the root is a single panel, wrap both in a Grid so they overlay; the IsVisible flags make them mutually exclusive.)
- Step 5: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 6: Commit
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
git commit -m "feat(ui): notes mode in the Details island"
Task 22: Pinned Notes row in My Day + selection routing
A pinned "Notes" row sits at the top of the Tasks island when the My Day smart list is active. Clicking it routes to Details.ShowNotes(). Selecting any real task routes through the existing Details.Bind(...) (which clears notes mode).
Files:
-
Modify:
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs(visibility flag + event + command) -
Modify:
src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml(pinned row UI) -
Modify:
src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs(route event to Details) -
Step 1: Add notes-row state to
TasksIslandViewModel
public event Action? NotesRequested;
// True only for the My Day smart list, so the pinned Notes row shows there.
[ObservableProperty] private bool _showNotesRow;
[RelayCommand]
private void OpenNotes()
{
SelectedTask = null; // visually deselect any task
NotesRequested?.Invoke();
}
Set ShowNotesRow where the active list is resolved (the same place smart:my-day is matched for filtering — see line 144 / the list-load path): ShowNotesRow = list.Id == "smart:my-day";.
- Step 2: Add the pinned row to
TasksIslandView.axaml
Above the task list (Items/OpenItems ListBox), add a row visible only for My Day:
<Button Classes="btn" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Margin="0,0,0,8"
IsVisible="{Binding ShowNotesRow}"
Command="{Binding OpenNotesCommand}"
Content="📝 Notizen (Tagesnotizen)"/>
(Match the surrounding row styling; the icon glyph is optional.)
- Step 3: Route the event in
IslandsShellViewModel
Where Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); is wired (line 193), add:
Tasks.NotesRequested += () => Details.ShowNotes();
- Step 4: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
git commit -m "feat(ui): pinned Notes row in My Day opens the notes editor"
Phase 7 — Full verification
Task 23: Build everything + run all tests
- Step 1: Build all projects
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj && dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
Expected: PASS.
- Step 2: Run the full test suites touched by this work
Run: dotnet test tests/ClaudeDo.Worker.Tests && dotnet test tests/ClaudeDo.Ui.Tests
Expected: PASS (all green, including the new DailyNote/WeekReport repo, reader, prompt-builder, service, range, and notes-editor tests).
Task 24: Manual smoke test (UI feature correctness)
Type/test checks verify code, not feature behavior. Do this manually:
- Step 1: Start the Worker, then the App.
- Step 2: Settings → set an excluded path (
C:\Private) and standup weekday (Mittwoch). Save. - Step 3: Select My Day → confirm the pinned Notizen row appears at the top. Click it → the Details island switches to the notes editor.
- Step 4: Add 2 bullets for today; step back a day with
‹, add one for yesterday; jump via the date picker; use Heute. Edit and delete a bullet. Confirm persistence by reselecting the day. - Step 5: Select a real task → confirm the Details island returns to task mode (notes editor hidden).
- Step 6: Help menu → Wochenbericht…. Confirm the default range is "letzter Mittwoch → heute". With no stored report, the Erstellen button shows.
- Step 7: Click Erstellen → busy state → a German, day-grouped markdown report renders, blending your bullet notes; repos under
C:\Privateare absent. - Step 8: Close and reopen the modal for the same range → the stored report renders immediately with no Claude call. Neu erstellen overwrites it.
- Step 9: Pick a range with no activity/notes → report shows "Keine Aktivität in diesem Zeitraum."
Task 25: Update docs
- Step 1: Update
CLAUDE.mdfiles where the spec touches documented surfaces:src/ClaudeDo.Data/CLAUDE.md— addDailyNoteEntity,WeekReportEntity, their repositories, and the newapp_settingscolumns +daily_notes/week_reportstables.src/ClaudeDo.Worker/CLAUDE.md— add theReport/folder and the new hub methods (GetWeekReport,GenerateWeekReport,GetDailyNotes,AddDailyNote,UpdateDailyNote,DeleteDailyNote).src/ClaudeDo.Ui/CLAUDE.md— note the Weekly Report modal, the Notes editor / My Day pinned row, and the newWorkerClientmethods.
- Step 2: Commit
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs: document weekly report and daily notes feature"
Self-Review
Spec coverage:
- All-history data source → Task 7 (
ClaudeHistoryReader). - Configurable excluded paths → Tasks 1/13/14 (settings) + Task 9 (applied) + Task 7 (enforced).
- Claude summarization (one-shot, no worktree) → Task 9.
- Default "since last Wednesday" range → Task 15 (
DefaultRange) + standup weekday setting. - Signal = prompts + closing summaries + notes → Task 7 (prompts/last-assistant) + Task 8/9 (notes blended).
- German, day-grouped, first-person, ≤3–5 bullets, notes merged → Task 8 (
WeekReportPromptBuilder). - Report modal, view-only, button-driven, cached/persisted → Tasks 5/9/15/16/17.
- Notes in My Day via pinned pseudo-row repurposing the Details island, day navigator (arrows + date picker + Today), per-day bullets → Tasks 18–22.
- Persistence keyed by range, reused, regenerate overwrites → Task 5 (repo) + Task 9 (upsert) + Task 15/16 (UI).
- Error handling (malformed skip, empty window, Claude failure) → Tasks 7/9.
- Tests (reader/service/prompt/repos + UI VMs) → Tasks 4,5,7,8,9,15,19.
Type consistency: DailyNoteDto(Id, Date, Text, SortOrder) identical on both sides (Tasks 11/12). AppSettingsDto extended with the same two trailing fields on both sides (Task 13). IWeekReportService.GenerateAsync/GetStoredAsync, IClaudeHistoryReader.ReadAsync, INotesApi signatures match their callers. Hub method names match WorkerClient invoke strings (GetWeekReport, GenerateWeekReport, GetDailyNotes, AddDailyNote, UpdateDailyNote, DeleteDailyNote).
Known soft spots (need a quick read during implementation, not placeholders): the exact General-settings-tab view file (Task 14 Step 4), the Details island root layout for hosting the notes editor (Task 21 Step 4), and the Tasks island list container for the pinned row (Task 22 Step 2). Each task names the file, the members to add, and the surrounding pattern to match.