summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDoot Agent <agent@doot.local>2026-07-05 08:22:17 +0000
committerDoot Agent <agent@doot.local>2026-07-05 08:22:17 +0000
commit8ca820b05e9e699a7b563a2e37ff2a0629a5505a (patch)
treeea9558a46143e2be3ff7f681242b3a896957441a
parent8c55b9b10fecd45ac5acf3f13fc23d342a4aa53b (diff)
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 <noreply@anthropic.com>
-rw-r--r--internal/handlers/atoms.go12
-rw-r--r--internal/handlers/atoms_test.go30
-rw-r--r--internal/handlers/handlers.go12
-rw-r--r--internal/handlers/handlers_test.go51
-rw-r--r--internal/handlers/widget_test.go19
-rw-r--r--internal/models/atom.go26
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)