Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions TaskTracker.Core.Tests/McpSurfaceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelContextProtocol.McpException>(() => 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);
}
}
122 changes: 122 additions & 0 deletions TaskTracker.Core.Tests/ReviewReportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentOutOfRangeException>(
() => ReviewReport.Compute(Array.Empty<ProjectModel>(), Now, days: 0));
}

[Fact]
public void Compute_Days_LongerThanTheCalendarClampsInsteadOfThrowing()
{
var review = ReviewReport.Compute(Array.Empty<ProjectModel>(), Now, days: int.MaxValue);

Assert.Equal(DateTime.MinValue, review.FromUtc);
}
}
61 changes: 50 additions & 11 deletions TaskTracker.Core/Services/ReviewReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,43 +10,82 @@ public record ProjectReview(
int Overdue,
double TrackedHours);

/// <param name="Label">
/// 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.
/// </param>
public record WeeklyReviewResult(
DateTime FromUtc,
DateTime ToUtc,
int TotalCompleted,
int TotalCreated,
IReadOnlyList<ProjectReview> Projects);
IReadOnlyList<ProjectReview> Projects,
string? Label = null);

/// <summary>What happened in the last seven days, per non-archived project.</summary>
/// <summary>What happened in a recent window, per non-archived project.</summary>
public static class ReviewReport
{
public static WeeklyReviewResult Compute(IEnumerable<ProjectModel> projects, DateTime? nowUtc = null)
/// <summary>The window every caller gets unless it asks for another.</summary>
public const int DefaultDays = 7;

/// <param name="label">
/// 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 <see cref="BoardFilter"/> uses.
/// </param>
/// <param name="days">Length of the window ending at <paramref name="nowUtc"/>.</param>
public static WeeklyReviewResult Compute(
IEnumerable<ProjectModel> 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<ProjectReview>();
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));
}

return new WeeklyReviewResult(
from, to,
rows.Sum(r => r.CompletedTitles.Count),
rows.Sum(r => r.Created),
rows);
rows,
scoped);
}
}
}
3 changes: 3 additions & 0 deletions TaskTracker.Mcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion TaskTracker.Mcp/TaskTracker.Mcp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<Version>2.0.0</Version>
<Version>2.1.0</Version>
<Description>MCP stdio server for TaskTracker: manage projects, tasks, board columns, checklists, trash and GitHub links from any MCP client.</Description>
<Authors>Sebastian Henn</Authors>
<PackageTags>mcp;tasktracker;claude;model-context-protocol</PackageTags>
Expand Down
3 changes: 3 additions & 0 deletions TaskTracker.Mcp/TaskTrackerPrompts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions TaskTracker.Mcp/TaskTrackerTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
/// <summary>
/// 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".
/// </summary>
[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.")]
Expand Down