diff --git a/README.md b/README.md index 0434444..6a22b67 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ optional for local development. | GET | `/api/tasks/{id}` | Fetch one task | | PUT | `/api/tasks/{id}` | Partial update of title/description/status | | DELETE | `/api/tasks/{id}` | Remove task (204) | +| DELETE | `/api/tasks?status=done` | Bulk remove by status, returns `{removed}` | Statuses: `todo`, `in_progress`, `done`. Errors return `{"error": "..."}` with 400 or 404. diff --git a/backend/include/taskboard/task_store.hpp b/backend/include/taskboard/task_store.hpp index 225e970..687d6cf 100644 --- a/backend/include/taskboard/task_store.hpp +++ b/backend/include/taskboard/task_store.hpp @@ -37,6 +37,9 @@ class TaskStore { bool remove(std::uint64_t id); + /// Removes every task with the given status. Returns how many were deleted. + std::size_t remove_by_status(Status status); + Stats stats() const; std::size_t size() const; void clear(); diff --git a/backend/src/api.cpp b/backend/src/api.cpp index 3b9e2f5..5b6b6a9 100644 --- a/backend/src/api.cpp +++ b/backend/src/api.cpp @@ -93,6 +93,20 @@ void register_routes(httplib::Server& server, TaskStore& store) { } }); + // Bulk delete: DELETE /api/tasks?status=done + server.Delete("/api/tasks", [&store](const httplib::Request& req, httplib::Response& res) { + if (!req.has_param("status")) { + send_error(res, 400, "status query parameter is required"); + return; + } + const auto status = status_from_string(req.get_param_value("status")); + if (!status) { + send_error(res, 400, "unknown status filter"); + return; + } + send_json(res, 200, json{{"removed", store.remove_by_status(*status)}}); + }); + server.Get(R"(/api/tasks/(\d+))", [&store](const httplib::Request& req, httplib::Response& res) { const auto id = parse_id(req.matches[1]); if (!id) { diff --git a/backend/src/task_store.cpp b/backend/src/task_store.cpp index 1081db9..9c5fe1c 100644 --- a/backend/src/task_store.cpp +++ b/backend/src/task_store.cpp @@ -68,6 +68,20 @@ bool TaskStore::remove(std::uint64_t id) { return tasks_.erase(id) > 0; } +std::size_t TaskStore::remove_by_status(Status status) { + std::lock_guard lock(mutex_); + std::size_t removed = 0; + for (auto it = tasks_.begin(); it != tasks_.end();) { + if (it->second.status == status) { + it = tasks_.erase(it); + ++removed; + } else { + ++it; + } + } + return removed; +} + Stats TaskStore::stats() const { std::lock_guard lock(mutex_); Stats s; diff --git a/backend/tests/api_test.cpp b/backend/tests/api_test.cpp index 7476d23..ae1f6a6 100644 --- a/backend/tests/api_test.cpp +++ b/backend/tests/api_test.cpp @@ -140,6 +140,26 @@ TEST_F(ApiTest, DeleteRemovesTask) { EXPECT_EQ(again->status, 404); } +TEST_F(ApiTest, BulkDeleteByStatus) { + post_task({{"title", "a"}, {"status", "done"}}); + post_task({{"title", "b"}}); + post_task({{"title", "c"}, {"status", "done"}}); + + auto res = client_->Delete("/api/tasks?status=done"); + ASSERT_TRUE(res); + EXPECT_EQ(res->status, 200); + EXPECT_EQ(json::parse(res->body)["removed"], 2); + EXPECT_EQ(store_.size(), 1u); + + auto missing_param = client_->Delete("/api/tasks"); + ASSERT_TRUE(missing_param); + EXPECT_EQ(missing_param->status, 400); + + auto bad_status = client_->Delete("/api/tasks?status=nope"); + ASSERT_TRUE(bad_status); + EXPECT_EQ(bad_status->status, 400); +} + TEST_F(ApiTest, ListFilterAndStats) { post_task({{"title", "a"}}); post_task({{"title", "b"}, {"status", "done"}}); diff --git a/backend/tests/task_store_test.cpp b/backend/tests/task_store_test.cpp index fc22343..937b058 100644 --- a/backend/tests/task_store_test.cpp +++ b/backend/tests/task_store_test.cpp @@ -105,6 +105,18 @@ TEST(TaskStore, RemoveReportsWhetherSomethingWasDeleted) { EXPECT_EQ(store.size(), 0u); } +TEST(TaskStore, RemoveByStatusDeletesOnlyMatching) { + TaskStore store; + store.create(input("a", "", Status::Done)); + store.create(input("b", "", Status::Todo)); + store.create(input("c", "", Status::Done)); + + EXPECT_EQ(store.remove_by_status(Status::Done), 2u); + EXPECT_EQ(store.size(), 1u); + EXPECT_EQ(store.list().front().title, "b"); + EXPECT_EQ(store.remove_by_status(Status::Done), 0u); +} + TEST(TaskStore, StatsCountPerStatus) { TaskStore store; store.create(input("a", "", Status::Todo)); diff --git a/frontend/app.js b/frontend/app.js index 04e468c..9ed9492 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -12,6 +12,7 @@ const errorBox = document.getElementById("error"); const healthDot = document.getElementById("health"); const template = document.getElementById("task-template"); + const clearDoneButton = document.getElementById("clear-done"); async function api(path, options = {}) { const res = await fetch(API_BASE + path, { @@ -57,6 +58,16 @@ for (const key of ["total", ...STATUSES]) { document.getElementById(`stat-${key}`).textContent = stats[key] ?? 0; } + clearDoneButton.disabled = (stats.done ?? 0) === 0; + } + + async function clearDone() { + try { + await api("/tasks?status=done", { method: "DELETE" }); + await refresh(); + } catch (err) { + showError(err.message); + } } async function refresh() { @@ -116,6 +127,8 @@ } }); + clearDoneButton.addEventListener("click", clearDone); + checkHealth(); refresh(); setInterval(checkHealth, 15000); diff --git a/frontend/index.html b/frontend/index.html index abc55cf..3899af9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -36,7 +36,10 @@

In progress

-

Done

+
+

Done

+ +
diff --git a/frontend/styles.css b/frontend/styles.css index fb7243e..8f6bfa3 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -103,6 +103,10 @@ button:disabled { opacity: 0.5; cursor: default; } color: var(--muted); } +.column-head { display: flex; align-items: center; justify-content: space-between; } +.column-head h2 { margin-bottom: 8px; } +.btn-clear { padding: 2px 8px; font-size: 0.75rem; margin-bottom: 8px; color: var(--danger); } + .tasks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } .task {