summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/handlers/handlers.go43
-rw-r--r--internal/handlers/handlers_test.go101
-rw-r--r--internal/store/chains_test.go114
-rw-r--r--internal/store/native_tasks.go61
-rw-r--r--internal/store/native_tasks_test.go20
5 files changed, 337 insertions, 2 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index ed15e89..30aaf67 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -15,6 +15,8 @@ import (
"sync"
"time"
+ "github.com/go-chi/chi/v5"
+
"task-dashboard/internal/api"
"task-dashboard/internal/auth"
"task-dashboard/internal/config"
@@ -951,6 +953,7 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
id := r.FormValue("id")
source := r.FormValue("source")
+ title := r.FormValue("title")
description := r.FormValue("description")
if id == "" || source == "" {
@@ -961,9 +964,17 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
var err error
switch source {
case "doot":
- err = h.store.UpdateNativeTaskDescription(id, description)
+ if title != "" {
+ err = h.store.UpdateNativeTask(id, title, description)
+ } else {
+ err = h.store.UpdateNativeTaskDescription(id, description)
+ }
case "trello":
- err = h.trelloClient.UpdateCard(r.Context(), id, map[string]interface{}{"desc": description})
+ updates := map[string]interface{}{"desc": description}
+ if title != "" {
+ updates["name"] = title
+ }
+ err = h.trelloClient.UpdateCard(r.Context(), id, updates)
default:
JSONError(w, http.StatusBadRequest, "Unknown source", nil)
return
@@ -981,6 +992,34 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
}
+// HandleDeleteTask permanently deletes a doot-native task. Other sources
+// (Trello cards, Google Tasks, calendar events) aren't deletable through
+// doot -- the task-detail modal only shows the Delete button for source ==
+// "doot" (task-detail.html's IsDoot flag), matching this scoping.
+func (h *Handler) HandleDeleteTask(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ source := r.URL.Query().Get("source")
+ if id == "" {
+ JSONError(w, http.StatusBadRequest, "Missing id", nil)
+ return
+ }
+ if source != "doot" {
+ JSONError(w, http.StatusBadRequest, "Delete is only supported for doot-native tasks", nil)
+ return
+ }
+
+ if err := h.store.DeleteNativeTask(id); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ JSONError(w, http.StatusNotFound, "task not found", err)
+ return
+ }
+ JSONError(w, http.StatusInternalServerError, "Failed to delete task", err)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
// HandleTabTasks renders the unified Tasks tab (native tasks + Trello cards with due dates + Google Tasks)
func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) {
atoms, boards, err := BuildUnifiedAtomList(h.store, h.claudomatorClient)
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index a43dec2..b618b71 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -3,6 +3,7 @@ package handlers
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -1910,6 +1911,106 @@ func TestHandleGetTaskDetail_DootSource_LoadsRealTaskFields(t *testing.T) {
}
}
+func TestHandleUpdateTask_DootSource_UpdatesTitleAndDescription(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Old title", Description: "Old desc", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("POST", "/tasks/update", strings.NewReader("id=task-1&source=doot&title=New+title&description=New+desc"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("HX-Request", "true")
+ w := httptest.NewRecorder()
+ h.HandleUpdateTask(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ task, err := h.store.GetNativeTaskByID("task-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.Content != "New title" {
+ t.Errorf("Content = %q, want %q", task.Content, "New title")
+ }
+ if task.Description != "New desc" {
+ t.Errorf("Description = %q, want %q", task.Description, "New desc")
+ }
+}
+
+func TestHandleUpdateTask_DootSource_NoTitle_LeavesContentUntouched(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Keep me", Description: "Old desc", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("POST", "/tasks/update", strings.NewReader("id=task-1&source=doot&description=New+desc"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.HandleUpdateTask(w, req)
+
+ if w.Code != http.StatusOK && w.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 200 or 303, body=%s", w.Code, w.Body.String())
+ }
+ task, err := h.store.GetNativeTaskByID("task-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.Content != "Keep me" {
+ t.Errorf("Content = %q, want unchanged %q (backward compat with task-detail-page.html, which has no title field)", task.Content, "Keep me")
+ }
+}
+
+func TestHandleDeleteTask_DootSource_RemovesTask(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Delete me", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := withURLParam(httptest.NewRequest("DELETE", "/tasks/task-1?source=doot", nil), "id", "task-1")
+ w := httptest.NewRecorder()
+ h.HandleDeleteTask(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ if _, err := h.store.GetNativeTaskByID("task-1"); !errors.Is(err, store.ErrNativeTaskNotFound) {
+ t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}
+
+func TestHandleDeleteTask_NonDootSource_Rejected(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := withURLParam(httptest.NewRequest("DELETE", "/tasks/card-1?source=trello", nil), "id", "card-1")
+ w := httptest.NewRecorder()
+ h.HandleDeleteTask(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleDeleteTask_UnknownID_ReturnsNotFound(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := withURLParam(httptest.NewRequest("DELETE", "/tasks/does-not-exist?source=doot", nil), "id", "does-not-exist")
+ w := httptest.NewRecorder()
+ h.HandleDeleteTask(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", w.Code)
+ }
+}
+
func TestHandleSetTaskRecurrence_SetsAndClears(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go
index 6359d11..bcfc09e 100644
--- a/internal/store/chains_test.go
+++ b/internal/store/chains_test.go
@@ -263,3 +263,117 @@ func TestGetChain_UnknownID_ReturnsErrNotFound(t *testing.T) {
t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
}
}
+
+// TestDeleteNativeTask_LockedChainTask_ClosesPositionGap proves deleting a
+// not-yet-reached chain step doesn't strand the chain: positions after the
+// deleted one shift down by one and stay a contiguous 0..N-1 sequence, since
+// advanceChain's chain_position+1 lookup depends on that contiguity.
+func TestDeleteNativeTask_LockedChainTask_ClosesPositionGap(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2", "Step 3"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.DeleteNativeTask(tasks[1].ID); err != nil {
+ t.Fatalf("DeleteNativeTask: %v", err)
+ }
+
+ after, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(after) != 2 {
+ t.Fatalf("len(after) = %d, want 2", len(after))
+ }
+ if after[0].Content != "Step 1" || after[1].Content != "Step 3" {
+ t.Errorf("unexpected content order: %q, %q", after[0].Content, after[1].Content)
+ }
+ if after[1].ChainPosition != 1 {
+ t.Errorf("Step 3 chain_position = %d, want 1 (gap closed)", after[1].ChainPosition)
+ }
+ if !after[0].ChainUnlocked {
+ t.Errorf("position 0 should still be unlocked")
+ }
+ if after[1].ChainUnlocked {
+ t.Errorf("position 1 (formerly locked position 2) should still be locked")
+ }
+
+ // Completing position 0 should now correctly advance to the
+ // renumbered position 1 (Step 3), proving advanceChain's
+ // chain_position+1 lookup still works post-deletion.
+ if err := s.CompleteNativeTask(after[0].ID); err != nil {
+ t.Fatalf("CompleteNativeTask: %v", err)
+ }
+ final, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !final[1].ChainUnlocked {
+ t.Errorf("Step 3 should be unlocked after completing Step 1")
+ }
+}
+
+// TestDeleteNativeTask_UnlockedChainTask_PromotesSuccessor proves deleting
+// the currently-actionable (unlocked) step unlocks whatever now sits at its
+// position, rather than leaving the chain with nothing unlocked.
+func TestDeleteNativeTask_UnlockedChainTask_PromotesSuccessor(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.DeleteNativeTask(tasks[0].ID); err != nil {
+ t.Fatalf("DeleteNativeTask: %v", err)
+ }
+
+ after, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(after) != 1 {
+ t.Fatalf("len(after) = %d, want 1", len(after))
+ }
+ if !after[0].ChainUnlocked || after[0].DueDate == nil {
+ t.Errorf("Step 2 should be promoted to unlocked with a due date, got ChainUnlocked=%v DueDate=%v", after[0].ChainUnlocked, after[0].DueDate)
+ }
+}
+
+// TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted proves
+// deleting the sole unlocked task with nothing left to promote finishes the
+// chain instead of leaving it active with zero unlocked tasks forever.
+func TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", chainTasks("Only step"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.DeleteNativeTask(tasks[0].ID); err != nil {
+ t.Fatalf("DeleteNativeTask: %v", err)
+ }
+
+ updatedChain, err := s.GetChain(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updatedChain.Status != "completed" {
+ t.Errorf("chain.Status = %q, want completed", updatedChain.Status)
+ }
+}
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go
index 11a9197..9d9af2c 100644
--- a/internal/store/native_tasks.go
+++ b/internal/store/native_tasks.go
@@ -161,6 +161,67 @@ func (s *Store) UpdateNativeTaskDescription(id, description string) error {
return err
}
+// DeleteNativeTask permanently removes a task. Returns ErrNativeTaskNotFound
+// if id doesn't match any row. If the task belongs to a chain, this also
+// closes the resulting gap in chain_position (advanceChain looks up
+// chain_position+1, so a gap would either strand the chain mid-sequence or,
+// if the deleted task was the unlocked one, silently stop it from ever
+// advancing) and, if the deleted task was itself the unlocked position,
+// unlocks whatever now occupies that position -- or marks the chain
+// completed if nothing does (the deleted task was the last one left).
+func (s *Store) DeleteNativeTask(id string) error {
+ task, err := s.GetNativeTaskByID(id)
+ if err != nil {
+ return err
+ }
+
+ if task.ChainID == "" {
+ result, err := s.db.Exec(`DELETE FROM native_tasks WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+ }
+
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ if _, err := tx.Exec(`DELETE FROM native_tasks WHERE id = ?`, id); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`
+ UPDATE native_tasks SET chain_position = chain_position - 1
+ WHERE chain_id = ? AND chain_position > ?
+ `, task.ChainID, task.ChainPosition); err != nil {
+ return err
+ }
+
+ if task.ChainUnlocked {
+ now := config.Now()
+ result, err := tx.Exec(`
+ UPDATE native_tasks SET chain_unlocked = 1, due_date = ?, updated_at = ?
+ WHERE chain_id = ? AND chain_position = ?
+ `, now, now, task.ChainID, task.ChainPosition)
+ if err != nil {
+ return err
+ }
+ successorPromoted, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if successorPromoted == 0 {
+ if _, err := tx.Exec(`UPDATE task_chains SET status = 'completed' WHERE id = ?`, task.ChainID); err != nil {
+ return err
+ }
+ }
+ }
+
+ return tx.Commit()
+}
+
// ErrChainTaskLocked is returned by CompleteNativeTask when the task
// belongs to a chain but isn't the currently-unlocked position -- without
// this guard, completing a locked task directly by id (bypassing the UI,
diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go
index d82576e..9a7e55f 100644
--- a/internal/store/native_tasks_test.go
+++ b/internal/store/native_tasks_test.go
@@ -698,3 +698,23 @@ func TestCreateNextIteration_CarriesEstimatedMinutesForward(t *testing.T) {
t.Errorf("EstimatedMinutes = %d, want 60 (carried forward)", next.EstimatedMinutes)
}
}
+
+func TestDeleteNativeTask_PlainTask_Removed(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if err := s.DeleteNativeTask("real-1"); err != nil {
+ t.Fatalf("DeleteNativeTask: %v", err)
+ }
+
+ if _, err := s.GetNativeTaskByID("real-1"); !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}
+
+func TestDeleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if err := s.DeleteNativeTask("does-not-exist"); !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}