Skip to content

Commit c432dfa

Browse files
committed
Merge feature/bulk-operations-#63: bulk task operations (v2.10.4)
2 parents 2d514e2 + d389078 commit c432dfa

17 files changed

Lines changed: 903 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
---
44

5+
## v2.10.4 - Bulk task operations
6+
*May 2026*
7+
8+
### Added
9+
10+
- `POST /api/tasks/bulk` - apply one operation to many tasks in a single transaction: `complete` (complete/reopen), `assignProject` (move to a project or Inbox), or `setDeadline` (set or clear). Returns the affected tasks; unknown ids are skipped. There is intentionally no bulk delete (#63)
11+
- Three MCP tools - `bulk_set_completion`, `bulk_assign_to_project`, `bulk_set_deadline` - so Claude can reorganize many tasks in one call (e.g. "move these 5 to Work", "push all of these to Friday") instead of one call per task. MCP tool count: 16 → 19
12+
- Web UI multi-select: a "Select" toggle on the task list reveals selection checkboxes (desktop column with select-all, mobile card checkboxes) and a sticky bulk-actions bar with Complete, Reopen, Move to project, and Set deadline
13+
14+
---
15+
516
## v2.10.3 - Computed dueStatus field + MCP tool-description shape hints
617
*May 2026*
718

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ I wanted a task system I understood completely - one where the data, the workflo
4949
**Track and complete**
5050
- Deadline color coding - red when overdue, yellow when due within 3 days
5151
- Edit a task's title, deadline, project, and labels from the list; quick-set deadlines (Today, Tomorrow, This weekend, Next week) or clear them with one tap
52+
- Multi-select to act on many tasks at once - complete, move to a project, or set a deadline in one step
5253
- Checkbox completion with a clean animation - done tasks step aside, not deleted
5354
- Show/hide completed tasks and undo completion at any time
5455
- Task detail page with full status and completion history
@@ -121,7 +122,7 @@ That's it. The app opens in your browser.
121122

122123
- Dark mode and custom themes
123124
- Dedicated device setup guide (Raspberry Pi, Term home server)
124-
- Task priority levels and bulk actions
125+
- Task priority levels
125126

126127
### v3.0 - AI and integrations
127128

backend/Tasklog.Api/Controllers/TasksController.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,78 @@ public async Task<IActionResult> SetLabels(int id, [FromBody] SetTaskLabelsReque
282282

283283
return Ok(task);
284284
}
285+
286+
// POST /api/tasks/bulk
287+
// Applies one operation to a set of tasks in a single transaction. Supported
288+
// operations: "complete" (data.isCompleted), "assignProject" (data.projectId,
289+
// null = Inbox), "setDeadline" (data.deadline, null = clear, ISO string = set).
290+
// No bulk delete - deletion stays single-task. Non-existent ids are skipped;
291+
// returns the affected tasks (with labels). 400 on bad input.
292+
[HttpPost("bulk")]
293+
public async Task<IActionResult> Bulk([FromBody] BulkTaskRequest request)
294+
{
295+
if (request.TaskIds is null || request.TaskIds.Count == 0)
296+
return BadRequest(new { message = "taskIds must be a non-empty array." });
297+
298+
// Cap the batch size server-side rather than trusting the client (the MCP
299+
// layer caps at 100, but a direct HTTP caller could send an unbounded list).
300+
const int maxBulk = 500;
301+
if (request.TaskIds.Count > maxBulk)
302+
return BadRequest(new { message = $"taskIds is limited to {maxBulk} per request." });
303+
304+
// Load only the tasks that exist (unknown ids are silently skipped) with
305+
// their labels so the response is fully populated.
306+
var tasks = await _context.Tasks
307+
.Include(t => t.Labels)
308+
.Where(t => request.TaskIds.Contains(t.Id))
309+
.ToListAsync();
310+
311+
var data = request.Data;
312+
313+
switch (request.Operation)
314+
{
315+
case "complete":
316+
if (data?.IsCompleted is not bool isCompleted)
317+
return BadRequest(new { message = "data.isCompleted is required for the complete operation." });
318+
foreach (var task in tasks)
319+
{
320+
task.IsCompleted = isCompleted;
321+
// Match the single-task Complete action: stamp/clear CompletedAt.
322+
task.CompletedAt = isCompleted ? DateTime.Now : null;
323+
}
324+
break;
325+
326+
case "assignProject":
327+
var projectId = data?.ProjectId;
328+
// Validate the destination project exists (null = Inbox is always valid).
329+
// Stricter than the single-task endpoint on purpose; bulk is higher-stakes.
330+
if (projectId is int pid && !await _context.Projects.AnyAsync(p => p.Id == pid))
331+
return BadRequest(new { message = $"Project {pid} not found." });
332+
foreach (var task in tasks)
333+
task.ProjectId = projectId;
334+
break;
335+
336+
case "setDeadline":
337+
DateTime? deadline = null;
338+
// null/absent clears the deadline; a string must be a valid ISO date.
339+
if (data?.Deadline is string raw)
340+
{
341+
if (!DateTime.TryParse(raw, out var parsed))
342+
return BadRequest(new { message = "data.deadline must be an ISO 8601 date string or null." });
343+
deadline = parsed;
344+
}
345+
foreach (var task in tasks)
346+
task.Deadline = deadline;
347+
break;
348+
349+
default:
350+
return BadRequest(new { message = $"Unknown operation '{request.Operation}'. Expected: complete, assignProject, setDeadline." });
351+
}
352+
353+
await _context.SaveChangesAsync();
354+
355+
return Ok(tasks);
356+
}
285357
}
286358

287359
// Request body shape for task creation.
@@ -296,6 +368,13 @@ public record AssignProjectRequest(int? ProjectId);
296368
// Request body shape for replacing a task's full label set.
297369
public record SetTaskLabelsRequest(int[] LabelIds);
298370

371+
// Request body shape for bulk operations. Operation is one of
372+
// "complete" / "assignProject" / "setDeadline". Data carries the per-operation
373+
// payload; Deadline is a string so it can be parsed and validated explicitly
374+
// (null = clear), mirroring the single-task PATCH.
375+
public record BulkTaskRequest(string Operation, List<int> TaskIds, BulkTaskData? Data);
376+
public record BulkTaskData(bool? IsCompleted, int? ProjectId, string? Deadline);
377+
299378
// Query-string shape for filtering the task list. All fields optional.
300379
// [FromQuery] binds:
301380
// projectIds - repeated keys, e.g. "?projectIds=3&projectIds=5" - this is

backend/Tasklog.Tests/TasksControllerTests.cs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,4 +655,183 @@ public async Task AssignProject_WhenTaskNotFound_Returns404()
655655

656656
result.Should().BeOfType<NotFoundObjectResult>();
657657
}
658+
659+
// --- Bulk (POST /api/tasks/bulk) ---
660+
661+
// Seeds three tasks and returns their ids alongside the controller + context.
662+
private static async Task<(TasksController controller, TasklogDbContext context, List<int> ids)> SeedThreeTasks()
663+
{
664+
var context = CreateContext();
665+
var tasks = new[]
666+
{
667+
new TaskModel { Title = "A", CreatedAt = DateTime.Now },
668+
new TaskModel { Title = "B", CreatedAt = DateTime.Now },
669+
new TaskModel { Title = "C", CreatedAt = DateTime.Now },
670+
};
671+
context.Tasks.AddRange(tasks);
672+
await context.SaveChangesAsync();
673+
return (new TasksController(context), context, tasks.Select(t => t.Id).ToList());
674+
}
675+
676+
[Fact]
677+
public async Task Bulk_Complete_SetsIsCompletedAndCompletedAt_OnAll()
678+
{
679+
var (controller, context, ids) = await SeedThreeTasks();
680+
using var _ = context;
681+
682+
var result = await controller.Bulk(new BulkTaskRequest("complete", ids, new BulkTaskData(true, null, null)));
683+
684+
var tasks = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeAssignableTo<List<TaskModel>>().Subject;
685+
tasks.Should().HaveCount(3);
686+
tasks.Should().OnlyContain(t => t.IsCompleted && t.CompletedAt != null);
687+
}
688+
689+
[Fact]
690+
public async Task Bulk_Complete_False_ClearsCompletedAt_OnAll()
691+
{
692+
var (controller, context, ids) = await SeedThreeTasks();
693+
using var _ = context;
694+
// Pre-complete them so the false path has something to clear.
695+
await controller.Bulk(new BulkTaskRequest("complete", ids, new BulkTaskData(true, null, null)));
696+
697+
var result = await controller.Bulk(new BulkTaskRequest("complete", ids, new BulkTaskData(false, null, null)));
698+
699+
var tasks = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeAssignableTo<List<TaskModel>>().Subject;
700+
tasks.Should().OnlyContain(t => !t.IsCompleted && t.CompletedAt == null);
701+
}
702+
703+
[Fact]
704+
public async Task Bulk_Complete_MissingIsCompleted_Returns400()
705+
{
706+
var (controller, context, ids) = await SeedThreeTasks();
707+
using var _ = context;
708+
709+
var result = await controller.Bulk(new BulkTaskRequest("complete", ids, new BulkTaskData(null, null, null)));
710+
711+
result.Should().BeOfType<BadRequestObjectResult>();
712+
}
713+
714+
[Fact]
715+
public async Task Bulk_AssignProject_MovesAll()
716+
{
717+
var (controller, context, ids) = await SeedThreeTasks();
718+
using var _ = context;
719+
var project = new Project { Name = "Work", CreatedAt = DateTime.UtcNow };
720+
context.Projects.Add(project);
721+
await context.SaveChangesAsync();
722+
723+
var result = await controller.Bulk(new BulkTaskRequest("assignProject", ids, new BulkTaskData(null, project.Id, null)));
724+
725+
var tasks = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeAssignableTo<List<TaskModel>>().Subject;
726+
tasks.Should().OnlyContain(t => t.ProjectId == project.Id);
727+
}
728+
729+
[Fact]
730+
public async Task Bulk_AssignProject_NullMovesAllToInbox()
731+
{
732+
var (controller, context, ids) = await SeedThreeTasks();
733+
using var _ = context;
734+
735+
var result = await controller.Bulk(new BulkTaskRequest("assignProject", ids, new BulkTaskData(null, null, null)));
736+
737+
var tasks = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeAssignableTo<List<TaskModel>>().Subject;
738+
tasks.Should().OnlyContain(t => t.ProjectId == null);
739+
}
740+
741+
[Fact]
742+
public async Task Bulk_AssignProject_MissingProject_Returns400()
743+
{
744+
var (controller, context, ids) = await SeedThreeTasks();
745+
using var _ = context;
746+
747+
var result = await controller.Bulk(new BulkTaskRequest("assignProject", ids, new BulkTaskData(null, 999, null)));
748+
749+
result.Should().BeOfType<BadRequestObjectResult>();
750+
}
751+
752+
[Fact]
753+
public async Task Bulk_SetDeadline_SetsOnAll()
754+
{
755+
var (controller, context, ids) = await SeedThreeTasks();
756+
using var _ = context;
757+
758+
var result = await controller.Bulk(new BulkTaskRequest("setDeadline", ids, new BulkTaskData(null, null, "2026-12-31")));
759+
760+
var tasks = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeAssignableTo<List<TaskModel>>().Subject;
761+
tasks.Should().OnlyContain(t => t.Deadline == new DateTime(2026, 12, 31));
762+
}
763+
764+
[Fact]
765+
public async Task Bulk_SetDeadline_NullClearsOnAll()
766+
{
767+
var (controller, context, ids) = await SeedThreeTasks();
768+
using var _ = context;
769+
await controller.Bulk(new BulkTaskRequest("setDeadline", ids, new BulkTaskData(null, null, "2026-12-31")));
770+
771+
var result = await controller.Bulk(new BulkTaskRequest("setDeadline", ids, new BulkTaskData(null, null, null)));
772+
773+
var tasks = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeAssignableTo<List<TaskModel>>().Subject;
774+
tasks.Should().OnlyContain(t => t.Deadline == null);
775+
}
776+
777+
[Fact]
778+
public async Task Bulk_SetDeadline_BadDate_Returns400()
779+
{
780+
var (controller, context, ids) = await SeedThreeTasks();
781+
using var _ = context;
782+
783+
var result = await controller.Bulk(new BulkTaskRequest("setDeadline", ids, new BulkTaskData(null, null, "not-a-date")));
784+
785+
result.Should().BeOfType<BadRequestObjectResult>();
786+
}
787+
788+
[Fact]
789+
public async Task Bulk_EmptyTaskIds_Returns400()
790+
{
791+
using var context = CreateContext();
792+
var controller = new TasksController(context);
793+
794+
var result = await controller.Bulk(new BulkTaskRequest("complete", new List<int>(), new BulkTaskData(true, null, null)));
795+
796+
result.Should().BeOfType<BadRequestObjectResult>();
797+
}
798+
799+
[Fact]
800+
public async Task Bulk_TooManyTaskIds_Returns400()
801+
{
802+
using var context = CreateContext();
803+
var controller = new TasksController(context);
804+
// 501 ids exceeds the server-side cap of 500.
805+
var tooMany = Enumerable.Range(1, 501).ToList();
806+
807+
var result = await controller.Bulk(new BulkTaskRequest("complete", tooMany, new BulkTaskData(true, null, null)));
808+
809+
result.Should().BeOfType<BadRequestObjectResult>();
810+
}
811+
812+
[Fact]
813+
public async Task Bulk_UnknownOperation_Returns400()
814+
{
815+
var (controller, context, ids) = await SeedThreeTasks();
816+
using var _ = context;
817+
818+
var result = await controller.Bulk(new BulkTaskRequest("explode", ids, null));
819+
820+
result.Should().BeOfType<BadRequestObjectResult>();
821+
}
822+
823+
[Fact]
824+
public async Task Bulk_UnknownIds_AreSkipped_ReturnsOnlyExisting()
825+
{
826+
var (controller, context, ids) = await SeedThreeTasks();
827+
using var _ = context;
828+
// Two real ids + one that does not exist.
829+
var mixed = new List<int> { ids[0], ids[1], 999999 };
830+
831+
var result = await controller.Bulk(new BulkTaskRequest("complete", mixed, new BulkTaskData(true, null, null)));
832+
833+
var tasks = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeAssignableTo<List<TaskModel>>().Subject;
834+
tasks.Should().HaveCount(2);
835+
tasks.Select(t => t.Id).Should().BeEquivalentTo(new[] { ids[0], ids[1] });
836+
}
658837
}

docs/architecture.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ Tasklog/
6565
│ │ ├── server.ts Hono HTTP entry, middleware wiring
6666
│ │ ├── config.ts Env var loading + production validation
6767
│ │ ├── api-client.ts Typed client for the Tasklog .NET API
68-
│ │ ├── tools/ 16 MCP tools wrapping every API endpoint
69-
│ │ │ ├── tasks.ts 8 task tools (list[+filters]/get/create/update/
70-
│ │ │ │ delete/set-completion/assign-project/set-labels)
68+
│ │ ├── tools/ 19 MCP tools wrapping every API endpoint
69+
│ │ │ ├── tasks.ts 11 task tools (list[+filters]/get/create/update/
70+
│ │ │ │ delete/set-completion/assign-project/set-labels +
71+
│ │ │ │ bulk-set-completion/bulk-assign-to-project/bulk-set-deadline)
7172
│ │ │ ├── projects.ts 4 project tools
7273
│ │ │ ├── labels.ts 4 label tools
7374
│ │ │ ├── registry.ts Aggregates and registers all tools
@@ -101,7 +102,8 @@ Tasklog/
101102
│ │ ├── DeleteTaskButton.tsx Delete action on detail page (Client Component)
102103
│ │ ├── CompleteTaskButton.tsx Complete/incomplete toggle on detail page (Client Component)
103104
│ │ ├── EditTaskModal.tsx Full task edit (title/deadline/project/labels), diff-and-fan-out save (Client Component, v2.10.2)
104-
│ │ └── DeadlinePopover.tsx Quick deadline preset picker on the deadline pill (Client Component, v2.10.2)
105+
│ │ ├── DeadlinePopover.tsx Quick deadline preset picker on the deadline pill (Client Component, v2.10.2)
106+
│ │ └── BulkActionsBar.tsx Sticky bulk-actions bar for multi-select mode (Client Component, v2.10.4)
105107
│ │ (list is representative - other components: TaskCard, FilterPanel, LabelsClient, etc.)
106108
│ └── lib/
107109
│ ├── api.ts Typed API call functions (used by both server and client)
@@ -184,6 +186,7 @@ LabelTaskModel (join table - implicit many-to-many)
184186
| DELETE | `/api/tasks/{id}` | Delete task. 204 on success, 404 if not found |
185187
| PATCH | `/api/tasks/{id}/complete` | Mark task complete or incomplete. Body: `{ isCompleted: bool }`. Returns updated task |
186188
| PATCH | `/api/tasks/{id}/project` | Reassign task to a project or Inbox. Body: `{ projectId: int? }` |
189+
| POST | `/api/tasks/bulk` | Apply one operation to many tasks in one transaction. Body: `{ operation: "complete" \| "assignProject" \| "setDeadline", taskIds: int[], data?: { isCompleted?, projectId?, deadline? } }`. No bulk delete. Unknown ids skipped; returns the affected tasks. 400 on empty ids / unknown op / invalid data (incl. assignProject to a missing project) |
187190
| GET | `/api/projects` | All projects, ordered by name |
188191
| POST | `/api/projects` | Create project. Body: `{ name: string }`. Returns created project |
189192
| PATCH | `/api/projects/{id}` | Rename project. Body: `{ name: string }`. Returns updated project |
@@ -372,7 +375,7 @@ POST /token auth_code and refresh_token gran
372375

373376
### Tool layer
374377

375-
16 MCP tools across three families (tasks: 8, projects: 4, labels: 4). Each tool is a thin wrapper around the corresponding Tasklog `/api` endpoint via `api-client.ts`. Input schemas use Zod and are inlined per tool. The `runTool()` helper in `result.ts` converts thrown `ApiError`s into MCP `isError: true` tool results (not JSON-RPC protocol errors), so the LLM can see and react to failures.
378+
19 MCP tools across three families (tasks: 11, projects: 4, labels: 4). The task family includes three bulk tools (`bulk_set_completion`, `bulk_assign_to_project`, `bulk_set_deadline`) backed by the single `POST /api/tasks/bulk` endpoint. Each tool is a thin wrapper around the corresponding Tasklog `/api` endpoint via `api-client.ts`. Input schemas use Zod and are inlined per tool. The `runTool()` helper in `result.ts` converts thrown `ApiError`s into MCP `isError: true` tool results (not JSON-RPC protocol errors), so the LLM can see and react to failures.
376379

377380
The `list_tasks` tool accepts an optional filter object (project, inbox, labels, deadline range, completion, title substring) that `api-client.ts` serializes into a query string on `GET /api/tasks`. Completion is a single `set_task_completion(id, isCompleted)` tool - the earlier `complete_task` / `uncomplete_task` split was consolidated in v2.10.1.
378381

docs/backlog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ What is currently being planned or built:
2222

2323
| Plan file | Issue | Branch | Status |
2424
|-----------|-------|--------|--------|
25-
| _none_ | | | |
25+
| [P63-bulk-operations.md](plans/P63-bulk-operations.md) | #63 | feature/bulk-operations-#63 | In Progress |
2626

2727
---
2828

0 commit comments

Comments
 (0)