From 8ca820b05e9e699a7b563a2e37ff2a0629a5505a Mon Sep 17 00:00:00 2001 From: Doot Agent Date: Sun, 5 Jul 2026 08:22:17 +0000 Subject: fix: native tasks missing from Tasks tab and blank title on completion Bug 1: BuildUnifiedAtomList never called GetNativeTasks(), so doot-sourced tasks were absent from the web Tasks tab. Added GetNativeTasks() fetch and NativeTaskToAtom conversion (new model helper, SourceDoot constant). Bug 2: handleAtomToggle called getAtomDetails after CompleteNativeTask, but GetNativeTasks filters WHERE completed=0, so the task was already gone and the title came back blank. Moved getAtomDetails call to before the completion switch so all sources (including doot) capture title/dueDate first. Also fixed widget_test.go compile error: TestHandleWidgetComplete_NonTodoist called HandleWidgetComplete as a package-level function but it is a method on *Handler. Rewrote the file as package handlers (internal) and construct &Handler{} directly. Regression tests added for both bugs in atoms_test.go and handlers_test.go. Co-Authored-By: Claude Sonnet 4.6 --- internal/handlers/atoms.go | 12 ++++++++- internal/handlers/atoms_test.go | 30 +++++++++++++++++++++- internal/handlers/handlers.go | 12 +++++++-- internal/handlers/handlers_test.go | 51 ++++++++++++++++++++++++++++++++++++++ internal/handlers/widget_test.go | 19 +++++++------- internal/models/atom.go | 26 +++++++++++++++++++ 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/internal/handlers/atoms.go b/internal/handlers/atoms.go index 6086a5b..0b59060 100644 --- a/internal/handlers/atoms.go +++ b/internal/handlers/atoms.go @@ -28,7 +28,17 @@ func BuildUnifiedAtomList(s *store.Store, claudomator api.ClaudomatorClient) ([] log.Printf("Warning: failed to fetch cached google tasks: %v", err) } - atoms := make([]models.Atom, 0, len(tasks)+len(gTasks)) + nativeTasks, err := s.GetNativeTasks() + if err != nil { + log.Printf("Warning: failed to fetch native tasks: %v", err) + } + + atoms := make([]models.Atom, 0, len(tasks)+len(gTasks)+len(nativeTasks)) + + // Add native doot tasks (GetNativeTasks already filters completed=0) + for _, task := range nativeTasks { + atoms = append(atoms, models.NativeTaskToAtom(task)) + } // Add incomplete tasks for _, task := range tasks { diff --git a/internal/handlers/atoms_test.go b/internal/handlers/atoms_test.go index 3be82f8..521b147 100644 --- a/internal/handlers/atoms_test.go +++ b/internal/handlers/atoms_test.go @@ -16,8 +16,36 @@ func (m *mockClaudomatorClient) GetActiveStories(ctx context.Context) ([]models. return m.stories, nil } +func TestBuildUnifiedAtomList_WithNativeTasks(t *testing.T) { + s, err := store.New(":memory:", "../../migrations") + if err != nil { + t.Fatalf("failed to create in-memory store: %v", err) + } + defer s.Close() + + if err := s.CreateNativeTask(models.Task{ID: "native-1", Content: "Buy milk"}); err != nil { + t.Fatalf("failed to create native task: %v", err) + } + + atoms, _, err := BuildUnifiedAtomList(s, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + found := false + for _, a := range atoms { + if a.Source == "doot" && a.Title == "Buy milk" { + found = true + break + } + } + if !found { + t.Errorf("expected atom with Source='doot' and Title='Buy milk'; got atoms: %+v", atoms) + } +} + func TestBuildUnifiedAtomList_WithClaudomator(t *testing.T) { - s, err := store.New(":memory:", "../store/migrations") + s, err := store.New(":memory:", "../../migrations") if err != nil { t.Fatalf("failed to create in-memory store: %v", err) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index eaef558..6c74fff 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -607,6 +607,14 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl return } + // Capture title/dueDate before mutating state so doot tasks (which are + // queried with WHERE completed=0) are still findable. + var preTitle string + var preDueDate *time.Time + if complete { + preTitle, preDueDate = h.getAtomDetails(id, source) + } + var err error switch source { case "doot": @@ -658,8 +666,8 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl } if complete { - // Get task details before removing from cache - title, dueDate := h.getAtomDetails(id, source) + // Use pre-fetched details (captured before mutation above). + title, dueDate := preTitle, preDueDate // Log to completed tasks _ = h.store.SaveCompletedTask(source, id, title, dueDate) diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 105641a..b640c78 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -3014,3 +3014,54 @@ func TestHandleTabMeals_GroupingMergesRecipes(t *testing.T) { } } +// TestHandleCompleteAtom_DootShowsTitle is a regression test for Bug 2: +// completing a doot/native task must render the correct title (not blank), even +// though GetNativeTasks filters WHERE completed=0 — the title must be captured +// before CompleteNativeTask is called. +func TestHandleCompleteAtom_DootShowsTitle(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + if err := db.CreateNativeTask(models.Task{ID: "doot-1", Content: "Feed the cat"}); err != nil { + t.Fatalf("failed to create native task: %v", err) + } + + renderer := NewMockRenderer() + h := &Handler{ + store: db, + config: &config.Config{}, + renderer: renderer, + } + + req := httptest.NewRequest("POST", "/complete-atom", nil) + req.Form = map[string][]string{"id": {"doot-1"}, "source": {"doot"}} + w := httptest.NewRecorder() + + h.HandleCompleteAtom(w, req) + + if len(renderer.Calls) != 1 { + t.Fatalf("expected 1 render call, got %d", len(renderer.Calls)) + } + call := renderer.Calls[0] + if call.Name != "completed-atom" { + t.Errorf("expected template 'completed-atom', got %q", call.Name) + } + + type atomData struct { + ID string + Source string + Title string + } + jsonBytes, _ := json.Marshal(call.Data) + var got atomData + if err := json.Unmarshal(jsonBytes, &got); err != nil { + t.Fatalf("failed to unmarshal render data: %v", err) + } + if got.Title != "Feed the cat" { + t.Errorf("expected Title 'Feed the cat', got %q (title was blank — regression of Bug 2)", got.Title) + } + if got.Source != "doot" { + t.Errorf("expected Source 'doot', got %q", got.Source) + } +} + diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index ed9d941..5b054a0 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -1,4 +1,4 @@ -package handlers_test +package handlers import ( "net/http" @@ -7,14 +7,13 @@ import ( "testing" "time" - "task-dashboard/internal/handlers" "task-dashboard/internal/models" ) func TestWidgetAuthMiddleware_NoToken(t *testing.T) { called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) - h := handlers.WidgetAuthMiddleware("secret", inner) + h := WidgetAuthMiddleware("secret", inner) req := httptest.NewRequest("GET", "/api/widget", nil) w := httptest.NewRecorder() @@ -30,7 +29,7 @@ func TestWidgetAuthMiddleware_NoToken(t *testing.T) { func TestWidgetAuthMiddleware_WrongToken(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - h := handlers.WidgetAuthMiddleware("secret", inner) + h := WidgetAuthMiddleware("secret", inner) req := httptest.NewRequest("GET", "/api/widget", nil) req.Header.Set("Authorization", "Bearer wrong") @@ -45,7 +44,7 @@ func TestWidgetAuthMiddleware_WrongToken(t *testing.T) { func TestWidgetAuthMiddleware_CorrectToken(t *testing.T) { called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) - h := handlers.WidgetAuthMiddleware("secret", inner) + h := WidgetAuthMiddleware("secret", inner) req := httptest.NewRequest("GET", "/api/widget", nil) req.Header.Set("Authorization", "Bearer secret") @@ -69,7 +68,7 @@ func TestTimelineItemToWidgetItem_Task(t *testing.T) { URL: "https://todoist.com/task/abc", } - wi := handlers.TimelineItemToWidgetItem(item) + wi := TimelineItemToWidgetItem(item) if wi.ID != "abc" { t.Errorf("ID: got %q, want %q", wi.ID, "abc") @@ -97,7 +96,7 @@ func TestTimelineItemToWidgetItem_Event(t *testing.T) { EndTime: &end, } - wi := handlers.TimelineItemToWidgetItem(item) + wi := TimelineItemToWidgetItem(item) if wi.Type != "event" { t.Errorf("Type: got %q, want %q", wi.Type, "event") @@ -111,11 +110,11 @@ func TestTimelineItemToWidgetItem_Event(t *testing.T) { } func TestHandleWidgetComplete_NonTodoist(t *testing.T) { - h := handlers.HandleWidgetComplete(nil) + h := &Handler{} body := `{"id":"x","source":"calendar"}` req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body)) w := httptest.NewRecorder() - h.ServeHTTP(w, req) + http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) @@ -124,7 +123,7 @@ func TestHandleWidgetComplete_NonTodoist(t *testing.T) { func TestWidgetAuthMiddleware_EmptyToken(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - h := handlers.WidgetAuthMiddleware("", inner) + h := WidgetAuthMiddleware("", inner) req := httptest.NewRequest("GET", "/api/widget", nil) req.Header.Set("Authorization", "Bearer ") diff --git a/internal/models/atom.go b/internal/models/atom.go index 3d9ce54..967b489 100644 --- a/internal/models/atom.go +++ b/internal/models/atom.go @@ -14,6 +14,7 @@ const ( SourceMeal AtomSource = "plantoeat" SourceGTasks AtomSource = "gtasks" SourceClaudomator AtomSource = "claudomator" + SourceDoot AtomSource = "doot" ) type AtomType string @@ -102,6 +103,31 @@ func TaskToAtom(t Task) Atom { } } +// NativeTaskToAtom converts a native doot Task to an Atom. +func NativeTaskToAtom(t Task) Atom { + priority := t.Priority + if priority < 1 { + priority = 1 + } + if priority > 4 { + priority = 4 + } + + return Atom{ + ID: t.ID, + Title: t.Content, + Description: t.Description, + Source: SourceDoot, + Type: TypeTask, + DueDate: t.DueDate, + CreatedAt: t.CreatedAt, + Priority: priority, + SourceIcon: "✅", + ColorClass: "border-green-500", + Raw: t, + } +} + // CardToAtom converts a Trello Card to an Atom func CardToAtom(c Card) Atom { // Trello doesn't have explicit priority, default to medium (2) -- cgit v1.2.3