diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-08-12 23:35:47 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-08-12 23:35:47 +0000 |
| commit | 2509dde6aa372a505b186657706f4d21bd391807 (patch) | |
| tree | e4147991747b8dd14d0b8654b2b8e50717b666bd /internal/handlers | |
| parent | 3e8ad60431d6cc783f9f7c555bfde5db54ebec75 (diff) | |
Add task title editing/deletion, timeline click-to-open, widget app launch
Task-detail modal was description-only with no delete affordance;
HandleUpdateTask now saves the title too and a Delete button hits a new
DELETE /tasks/{id} route backed by store.DeleteNativeTask, which repairs
chain_position/unlocks the successor when the deleted task belongs to a
chain. Timeline tab task/card/gtask rows now open the same detail modal
as the Tasks tab. Android widget's "TODAY" header is now a tap target
that launches DashboardActivity, since nothing previously opened the
full app from the widget.
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/handlers.go | 43 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 101 |
2 files changed, 142 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() |
