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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions backend/include/taskboard/task_store.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions backend/src/api.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions backend/src/task_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions backend/tests/api_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"}});
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/task_store_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
13 changes: 13 additions & 0 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -116,6 +127,8 @@
}
});

clearDoneButton.addEventListener("click", clearDone);

checkHealth();
refresh();
setInterval(checkHealth, 15000);
Expand Down
5 changes: 4 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ <h2>In progress</h2>
<ul class="tasks" id="col-in_progress"></ul>
</div>
<div class="column" data-status="done">
<h2>Done</h2>
<div class="column-head">
<h2>Done</h2>
<button id="clear-done" class="btn-clear" type="button" title="Delete all done tasks">Clear done</button>
</div>
<ul class="tasks" id="col-done"></ul>
</div>
</section>
Expand Down
4 changes: 4 additions & 0 deletions frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading