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.")]