From cf376e4224e47c53b4122170f30313db0389071f Mon Sep 17 00:00:00 2001 From: Sebastian Henn <138793908+SebHenn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:30:33 +0200 Subject: [PATCH] Let weekly_review answer for one effort instead of everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit weekly_review took no parameters, so the only report that crosses projects could not be pointed at anything smaller than the whole store. An effort spread over five boards came back as sixteen rows, eleven of them listed purely because they had open tasks — and the label the effort had been tagging every task with was write-only at the review level: dutifully set, never read. ReviewReport.Compute now takes a label and a window: - label scopes every count and title to the tasks carrying it, and lists only the boards that have one. Matched case-insensitively and trimmed, the same rule BoardFilter uses, and echoed back on the result so an empty report is distinguishable from a mistyped label. Nulls are dropped from tool output, so an unscoped call costs nothing extra. - days replaces the hardcoded 7. Clamped rather than thrown at when the window is longer than the calendar; the tool rejects days < 1 with an actionable message. A scoped row is kept whenever the board carries the label at all, not only when it was active in the window. Unscoped, an empty row is noise; scoped, the label has already done the narrowing, and finished work plus tracked hours is half of "what did this effort cost". The unscoped path is unchanged, which is what the home page's "this week" section still calls. Fixes #3 Co-Authored-By: Claude Opus 5 --- README.md | 6 + TaskTracker.Core.Tests/McpSurfaceTests.cs | 55 +++++++++ TaskTracker.Core.Tests/ReviewReportTests.cs | 122 ++++++++++++++++++++ TaskTracker.Core/Services/ReviewReport.cs | 61 ++++++++-- TaskTracker.Mcp/Program.cs | 3 + TaskTracker.Mcp/TaskTracker.Mcp.csproj | 2 +- TaskTracker.Mcp/TaskTrackerPrompts.cs | 3 + TaskTracker.Mcp/TaskTrackerTools.cs | 16 ++- 8 files changed, 253 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2af8c52..fd29d6a 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,12 @@ compact default costs roughly a fifth of what the full indented form does. `due` (`any`/`overdue`/`today`/`week`/`none`) and `priority`, using the same due buckets as the desktop board and the Today dashboard. +`weekly_review` is the only report that spans projects, so it takes the same +`label` filter — the way to review an effort whose boards are not known in +advance, since a label is set per task rather than per project. Scoped, it counts +only the tasks carrying the label and lists only the boards that have one; `days` +widens the window past the default 7. + ### Using TaskTracker from another repository The `.mcp.json` above resolves `--project TaskTracker.Mcp` relative to the diff --git a/TaskTracker.Core.Tests/McpSurfaceTests.cs b/TaskTracker.Core.Tests/McpSurfaceTests.cs index 08294ad..eed6d0b 100644 --- a/TaskTracker.Core.Tests/McpSurfaceTests.cs +++ b/TaskTracker.Core.Tests/McpSurfaceTests.cs @@ -386,4 +386,59 @@ public void SubtasksAndNotesCanBeRemovedAgain() Assert.Empty(Reload().Tasks[0].SubTasks); Assert.Empty(Reload().Tasks[0].Activity); } + + [Fact] + public void WeeklyReview_ScopedToALabel_ReportsOnlyTheBoardsThatEffortTouches() + { + // The whole point of the label: the set of boards an effort spans is not known + // in advance, so it cannot be expressed as a list of project ids. + SeedNamed("cifail", ("port the analyzer", "cartographer"), ("unrelated chore", null)); + SeedNamed("dotnet-tia", ("wire up the diff", "cartographer")); + SeedNamed("Wordle", ("guess five letters", null)); + + var review = Parse(TaskTrackerTools.WeeklyReview(label: "cartographer")); + + var names = review.GetProperty("Projects").EnumerateArray() + .Select(p => p.GetProperty("ProjectName").GetString()) + .ToList(); + Assert.Equal(new[] { "cifail", "dotnet-tia" }, names); + Assert.Equal("cartographer", review.GetProperty("Label").GetString()); + Assert.Equal(2, review.GetProperty("TotalCreated").GetInt32()); + } + + [Fact] + public void WeeklyReview_Unscoped_OmitsTheLabelKeyEntirely() + { + SeedNamed("Wordle", ("guess five letters", null)); + + var review = Parse(TaskTrackerTools.WeeklyReview()); + + Assert.False(review.TryGetProperty("Label", out _)); + } + + [Fact] + public void WeeklyReview_RejectsAWindowThatIsNotAWindow() + { + var ex = Assert.Throws(() => TaskTrackerTools.WeeklyReview(days: 0)); + Assert.Contains("days", ex.Message); + } + + private void SeedNamed(string projectName, params (string Title, string? Label)[] tasks) + { + var project = new ProjectModel { Name = projectName }; + foreach (var column in BoardColumnDefaults.NewProjectColumns()) + project.Columns.Add(column); + foreach (var (title, label) in tasks) + { + var task = new TaskModel { Title = title, ColumnId = project.FirstColumn!.Id }; + if (label != null) + task.Labels.Add(label); + project.Tasks.Add(task); + } + + var store = Store; + var data = store.Load(); + data.Projects.Add(project); + store.Save(data); + } } diff --git a/TaskTracker.Core.Tests/ReviewReportTests.cs b/TaskTracker.Core.Tests/ReviewReportTests.cs index 21729a5..770970b 100644 --- a/TaskTracker.Core.Tests/ReviewReportTests.cs +++ b/TaskTracker.Core.Tests/ReviewReportTests.cs @@ -41,4 +41,126 @@ public void Compute_SkipsArchivedAndInactiveProjects() Assert.Empty(review.Projects); Assert.Equal(0, review.TotalCompleted); } + + // ---------- Scoping the review to one effort ---------- + + private static ProjectModel Labelled(string name, params TaskModel[] tasks) + { + var project = new ProjectModel { Name = name }; + foreach (var task in tasks) + project.Tasks.Add(task); + return project; + } + + private static TaskModel NewTask(string title, string? label = null, bool done = false, DateTime? completed = null, DateTime? created = null) + { + var task = new TaskModel { Title = title, IsDone = done, CompletedAtUtc = completed, CreatedAtUtc = created ?? Now.AddDays(-1) }; + if (label != null) + task.Labels.Add(label); + return task; + } + + [Fact] + public void Compute_Label_CountsOnlyLabelledTasks_AndDropsBoardsWithout() + { + // The reported case: an effort spread over several boards, each of which also + // carries unrelated work, and eleven more boards that carry none of it. + var effort = Labelled("cifail", + NewTask("labelled shipped", "cartographer", done: true, completed: Now.AddDays(-2), created: Now.AddDays(-3)), + NewTask("labelled open", "cartographer"), + NewTask("unrelated open")); + var unrelated = Labelled("Wordle", NewTask("someone else's open task")); + + var review = ReviewReport.Compute(new[] { effort, unrelated }, Now, label: "cartographer"); + + var row = Assert.Single(review.Projects); + Assert.Equal("cifail", row.ProjectName); + Assert.Equal(new[] { "labelled shipped" }, row.CompletedTitles); + Assert.Equal(1, row.Open); + Assert.Equal(2, row.Created); + Assert.Equal(1, review.TotalCompleted); + Assert.Equal("cartographer", review.Label); + } + + [Fact] + public void Compute_Label_MatchesCaseInsensitivelyAndIgnoresSurroundingSpace() + { + var project = Labelled("P", NewTask("t", "Cartographer")); + + var review = ReviewReport.Compute(new[] { project }, Now, label: " cartographer "); + + Assert.Single(review.Projects); + Assert.Equal("cartographer", review.Label); + } + + [Fact] + public void Compute_Label_KeepsABoardWhoseLabelledWorkIsAlreadyFinished() + { + // Unscoped this row would be dropped as noise. Scoped it is half the answer: + // "what did this effort cost" is exactly the finished work and its tracked hours. + var finished = Labelled("done and dusted", + NewTask("shipped long ago", "cartographer", done: true, completed: Now.AddDays(-40), created: Now.AddDays(-50))); + finished.Tasks[0].TrackedSeconds = 3600; + + var review = ReviewReport.Compute(new[] { finished }, Now, label: "cartographer"); + + var row = Assert.Single(review.Projects); + Assert.Empty(row.CompletedTitles); + Assert.Equal(0, row.Open); + Assert.Equal(1, row.TrackedHours); + } + + [Fact] + public void Compute_Label_ThatNothingCarries_ReportsNothingRatherThanEverything() + { + var project = Labelled("P", NewTask("t", "chore")); + + var review = ReviewReport.Compute(new[] { project }, Now, label: "typo"); + + Assert.Empty(review.Projects); + Assert.Equal(0, review.TotalCompleted); + Assert.Equal("typo", review.Label); + } + + [Fact] + public void Compute_WithoutALabel_IsUnchanged_AndSaysSo() + { + var project = Labelled("P", NewTask("t", "chore")); + + var review = ReviewReport.Compute(new[] { project }, Now); + + Assert.Single(review.Projects); + Assert.Null(review.Label); + Assert.Equal(7, (review.ToUtc - review.FromUtc).TotalDays); + } + + [Fact] + public void Compute_Days_WidensTheWindow() + { + var project = Labelled("P", + NewTask("recent", done: true, completed: Now.AddDays(-2)), + NewTask("older", done: true, completed: Now.AddDays(-20))); + + var week = ReviewReport.Compute(new[] { project }, Now); + var month = ReviewReport.Compute(new[] { project }, Now, days: 30); + + Assert.Equal(new[] { "recent" }, week.Projects.Single().CompletedTitles); + Assert.Equal(new[] { "older", "recent" }, month.Projects.Single().CompletedTitles); + Assert.Equal(Now.AddDays(-30), month.FromUtc); + } + + [Fact] + public void Compute_Days_RejectsAWindowThatIsNotAWindow() + { + Assert.Throws( + () => ReviewReport.Compute(Array.Empty(), Now, days: 0)); + } + + [Fact] + public void Compute_Days_LongerThanTheCalendarClampsInsteadOfThrowing() + { + var review = ReviewReport.Compute(Array.Empty(), Now, days: int.MaxValue); + + Assert.Equal(DateTime.MinValue, review.FromUtc); + } } diff --git a/TaskTracker.Core/Services/ReviewReport.cs b/TaskTracker.Core/Services/ReviewReport.cs index 8a69354..bce5a7d 100644 --- a/TaskTracker.Core/Services/ReviewReport.cs +++ b/TaskTracker.Core/Services/ReviewReport.cs @@ -10,35 +10,73 @@ public record ProjectReview( int Overdue, double TrackedHours); + /// + /// The label the report was scoped to, echoed back so a caller can tell an empty + /// report from a mistyped label. Null when the report covers everything. + /// public record WeeklyReviewResult( DateTime FromUtc, DateTime ToUtc, int TotalCompleted, int TotalCreated, - IReadOnlyList Projects); + IReadOnlyList Projects, + string? Label = null); - /// What happened in the last seven days, per non-archived project. + /// What happened in a recent window, per non-archived project. public static class ReviewReport { - public static WeeklyReviewResult Compute(IEnumerable projects, DateTime? nowUtc = null) + /// The window every caller gets unless it asks for another. + public const int DefaultDays = 7; + + /// + /// When set, every count and title below is measured over only the tasks carrying + /// it, and a project is listed if it has any such task at all. This is the one + /// report that crosses projects, so a label is the only way to ask about an effort + /// whose set of boards is not known in advance. Matched case-insensitively, the + /// same rule uses. + /// + /// Length of the window ending at . + public static WeeklyReviewResult Compute( + IEnumerable projects, + DateTime? nowUtc = null, + string? label = null, + int days = DefaultDays) { + ArgumentOutOfRangeException.ThrowIfLessThan(days, 1); + var to = nowUtc ?? DateTime.UtcNow; - var from = to.AddDays(-7); + // A caller may ask for a window longer than the calendar; clamping keeps an + // absurd-but-positive value from throwing out of AddDays. + var from = days < (to - DateTime.MinValue).TotalDays ? to.AddDays(-days) : DateTime.MinValue; + + var scoped = string.IsNullOrWhiteSpace(label) ? null : label.Trim(); var rows = new List(); foreach (var project in projects.Where(p => !p.IsArchived)) { - var completed = project.Tasks + var tasks = scoped == null + ? project.Tasks.ToList() + : project.Tasks.Where(t => t.Labels.Contains(scoped, StringComparer.OrdinalIgnoreCase)).ToList(); + + var completed = tasks .Where(t => t.CompletedAtUtc >= from && t.CompletedAtUtc <= to) .OrderBy(t => t.CompletedAtUtc) .Select(t => t.Title) .ToList(); - var created = project.Tasks.Count(t => t.CreatedAtUtc >= from && t.CreatedAtUtc <= to); - var open = project.Tasks.Count(t => !t.IsDone); - var overdue = project.Tasks.Count(t => t.IsOverdue); - var trackedHours = Math.Round(project.Tasks.Sum(t => t.TrackedSeconds) / 3600, 2); + var created = tasks.Count(t => t.CreatedAtUtc >= from && t.CreatedAtUtc <= to); + var open = tasks.Count(t => !t.IsDone); + var overdue = tasks.Count(t => t.IsOverdue); + var trackedHours = Math.Round(tasks.Sum(t => t.TrackedSeconds) / 3600, 2); + + // Unscoped, a row with nothing in it is noise, so quiet boards are dropped. + // Scoped, the label has already done the narrowing, so a board that carries + // it belongs in the report even if it was quiet in this window: its finished + // work and its tracked hours are half of "what did this effort cost". + var include = scoped == null + ? completed.Count > 0 || created > 0 || open > 0 + : tasks.Count > 0; - if (completed.Count > 0 || created > 0 || open > 0) + if (include) rows.Add(new ProjectReview(project.Name, completed, created, open, overdue, trackedHours)); } @@ -46,7 +84,8 @@ public static WeeklyReviewResult Compute(IEnumerable projects, Dat from, to, rows.Sum(r => r.CompletedTitles.Count), rows.Sum(r => r.Created), - rows); + rows, + scoped); } } } diff --git a/TaskTracker.Mcp/Program.cs b/TaskTracker.Mcp/Program.cs index 5b78f5e..e628a7d 100644 --- a/TaskTracker.Mcp/Program.cs +++ b/TaskTracker.Mcp/Program.cs @@ -41,6 +41,9 @@ column and the flag in agreement. descriptions. Pass detail=full when you need the body, and use limit/offset to page — the response reports the unpaged total. - Recurrence is one of none, daily, weekly, monthly. + - weekly_review is the only report that crosses projects. Scope it with label to + review an effort spread over several boards as one thing, and with days to + widen the window past the default 7. """; }) .WithStdioServerTransport() diff --git a/TaskTracker.Mcp/TaskTracker.Mcp.csproj b/TaskTracker.Mcp/TaskTracker.Mcp.csproj index a878a85..1ed58f1 100644 --- a/TaskTracker.Mcp/TaskTracker.Mcp.csproj +++ b/TaskTracker.Mcp/TaskTracker.Mcp.csproj @@ -40,7 +40,7 @@ version, so forgetting leaves users on old code with no signal. Major for a removed/renamed tool or a changed response shape, minor for a new tool, patch for a fix. store_info reports the running value. --> - 2.0.0 + 2.1.0 MCP stdio server for TaskTracker: manage projects, tasks, board columns, checklists, trash and GitHub links from any MCP client. Sebastian Henn mcp;tasktracker;claude;model-context-protocol diff --git a/TaskTracker.Mcp/TaskTrackerPrompts.cs b/TaskTracker.Mcp/TaskTrackerPrompts.cs index 51b6241..e002fac 100644 --- a/TaskTracker.Mcp/TaskTrackerPrompts.cs +++ b/TaskTracker.Mcp/TaskTrackerPrompts.cs @@ -30,6 +30,9 @@ ones you would let slip. public static string ReviewMyWeek() => """ Call weekly_review for the last seven days, then due_overview for what is coming. + If the user named an effort that spans several boards, pass its label to + weekly_review (list_labels has the spellings) so the review is about that + effort rather than about everything. Write a short review: - What was completed, grouped by project. Lead with the substantial items rather diff --git a/TaskTracker.Mcp/TaskTrackerTools.cs b/TaskTracker.Mcp/TaskTrackerTools.cs index 52db5e5..8dfdc1c 100644 --- a/TaskTracker.Mcp/TaskTrackerTools.cs +++ b/TaskTracker.Mcp/TaskTrackerTools.cs @@ -409,11 +409,21 @@ public static string AddNote( return ToJson(new { entry!.Id, entry.AtUtc, entry.Text }); } - [McpServerTool(Name = "weekly_review", ReadOnly = true, Title = "Weekly review"), Description("What happened in the last 7 days: completed and created tasks per project, plus open/overdue counts and tracked hours.")] - public static string WeeklyReview() + /// + /// The only report that crosses projects, which is why it is the only one where a + /// label earns its keep: list_tasks can already answer "which of this project's tasks + /// carry it", but not "which boards is this effort spread over, and what is left". + /// + [McpServerTool(Name = "weekly_review", ReadOnly = true, Title = "Weekly review"), Description("What happened recently, across projects: completed and created tasks per project, plus open/overdue counts and tracked hours. Scope it to a label to review one cross-project effort as a whole.")] + public static string WeeklyReview( + [Description("Only tasks carrying this label, and only projects that have one (see list_labels for the exact spelling)")] string? label = null, + [Description("Length of the window in days, counting back from now (default 7)")] int days = ReviewReport.DefaultDays) { + if (days < 1) + throw new McpException($"days must be at least 1 (got {days})."); + var data = LoadData(); - return ToJson(ReviewReport.Compute(data.Projects)); + return ToJson(ReviewReport.Compute(data.Projects, label: label, days: days)); } [McpServerTool(Name = "update_project", Idempotent = true, Title = "Update project"), Description("Update a project's name, description, or archived state. Only provided fields change.")]