summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/google_tasks.go12
-rw-r--r--internal/api/interfaces.go1
-rw-r--r--internal/api/trello.go13
-rw-r--r--internal/handlers/handlers.go6
-rw-r--r--internal/handlers/timeline_logic_test.go1
-rw-r--r--internal/handlers/widget.go168
-rw-r--r--internal/handlers/widget_test.go361
-rw-r--r--internal/models/types.go15
-rw-r--r--internal/store/native_tasks.go12
-rw-r--r--internal/store/sqlite.go85
-rw-r--r--internal/store/sqlite_test.go222
11 files changed, 840 insertions, 56 deletions
diff --git a/internal/api/google_tasks.go b/internal/api/google_tasks.go
index da3cd3b..b580be4 100644
--- a/internal/api/google_tasks.go
+++ b/internal/api/google_tasks.go
@@ -163,6 +163,18 @@ func (c *GoogleTasksClient) CompleteTask(ctx context.Context, listID, taskID str
return nil
}
+// UpdateTaskNotes updates a task's notes (description)
+func (c *GoogleTasksClient) UpdateTaskNotes(ctx context.Context, listID, taskID, notes string) error {
+ task := &tasks.Task{
+ Notes: notes,
+ }
+ _, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do()
+ if err != nil {
+ return fmt.Errorf("failed to update task notes: %v", err)
+ }
+ return nil
+}
+
// UncompleteTask marks a task as not completed
func (c *GoogleTasksClient) UncompleteTask(ctx context.Context, listID, taskID string) error {
task := &tasks.Task{
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go
index 9a607bb..3c1e4e1 100644
--- a/internal/api/interfaces.go
+++ b/internal/api/interfaces.go
@@ -39,6 +39,7 @@ type GoogleTasksAPI interface {
GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error)
CompleteTask(ctx context.Context, listID, taskID string) error
UncompleteTask(ctx context.Context, listID, taskID string) error
+ UpdateTaskNotes(ctx context.Context, listID, taskID, notes string) error
GetTaskLists(ctx context.Context) ([]models.TaskListInfo, error)
SetTaskListID(id string)
}
diff --git a/internal/api/trello.go b/internal/api/trello.go
index 1a5642c..d580d98 100644
--- a/internal/api/trello.go
+++ b/internal/api/trello.go
@@ -88,7 +88,7 @@ func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) {
func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) {
params := c.authParams()
params.Set("filter", "open")
- params.Set("fields", "id,name,idList,due,url,idBoard")
+ params.Set("fields", "id,name,desc,idList,due,url,idBoard")
var apiCards []trelloCardResponse
path := fmt.Sprintf("/boards/%s/cards?%s", boardID, params.Encode())
@@ -116,11 +116,12 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C
cards := make([]models.Card, 0, len(apiCards))
for _, apiCard := range apiCards {
card := models.Card{
- ID: apiCard.ID,
- Name: apiCard.Name,
- ListID: apiCard.IDList,
- ListName: listMap[apiCard.IDList],
- URL: apiCard.URL,
+ ID: apiCard.ID,
+ Name: apiCard.Name,
+ Description: apiCard.Desc,
+ ListID: apiCard.IDList,
+ ListName: listMap[apiCard.IDList],
+ URL: apiCard.URL,
}
if apiCard.Due != nil && *apiCard.Due != "" {
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 0d8b2da..408006d 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -698,7 +698,7 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) {
for _, b := range boards {
for _, c := range b.Cards {
if c.ID == id {
- title = c.Name
+ title, description = c.Name, c.Description
break
}
}
@@ -740,7 +740,7 @@ func (h *Handler) HandleTaskDetailPage(w http.ResponseWriter, r *http.Request) {
for _, b := range boards {
for _, c := range b.Cards {
if c.ID == id {
- title = c.Name
+ title, description = c.Name, c.Description
break
}
}
@@ -785,7 +785,7 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) {
var err error
switch source {
case "doot":
- err = h.store.UpdateNativeTask(id, "", description)
+ err = h.store.UpdateNativeTaskDescription(id, description)
case "trello":
err = h.trelloClient.UpdateCard(r.Context(), id, map[string]interface{}{"desc": description})
default:
diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go
index 5e7cb27..1da2d8a 100644
--- a/internal/handlers/timeline_logic_test.go
+++ b/internal/handlers/timeline_logic_test.go
@@ -75,6 +75,7 @@ func setupTestStore(t *testing.T) *store.Store {
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
+ description TEXT DEFAULT '',
board_id TEXT NOT NULL,
list_id TEXT,
list_name TEXT,
diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go
index 6b1e774..f1f7452 100644
--- a/internal/handlers/widget.go
+++ b/internal/handlers/widget.go
@@ -46,9 +46,12 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem {
wi.Type = "event"
case models.TimelineItemTypeMeal:
wi.Type = "event"
- case models.TimelineItemTypeCard, models.TimelineItemTypeGTask:
+ case models.TimelineItemTypeCard:
wi.Type = "task"
- // not completable via widget API
+ wi.Completable = true
+ case models.TimelineItemTypeGTask:
+ wi.Type = "task"
+ wi.Completable = true
default:
wi.Type = "task"
wi.Completable = item.Source == "doot"
@@ -164,6 +167,135 @@ func (h *Handler) HandleWidgetReschedule(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK)
}
+// findGoogleTask looks up a cached Google Task by ID.
+func (h *Handler) findGoogleTask(id string) (models.GoogleTask, bool) {
+ gTasks, err := h.store.GetGoogleTasks()
+ if err != nil {
+ return models.GoogleTask{}, false
+ }
+ for _, t := range gTasks {
+ if t.ID == id {
+ return t, true
+ }
+ }
+ return models.GoogleTask{}, false
+}
+
+// findCard looks up a cached Trello card by ID.
+func (h *Handler) findCard(id string) (models.Card, bool) {
+ boards, err := h.store.GetBoards()
+ if err != nil {
+ return models.Card{}, false
+ }
+ for _, b := range boards {
+ for _, c := range b.Cards {
+ if c.ID == id {
+ return c, true
+ }
+ }
+ }
+ return models.Card{}, false
+}
+
+type widgetDetailResponse struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Editable bool `json:"editable"`
+}
+
+// HandleWidgetDetail returns a task's title/description for the widget's edit popup.
+func (h *Handler) HandleWidgetDetail(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ source := r.URL.Query().Get("source")
+ if id == "" || source == "" {
+ http.Error(w, "missing id or source", http.StatusBadRequest)
+ return
+ }
+
+ var resp widgetDetailResponse
+ switch source {
+ case "doot":
+ tasks, err := h.store.GetNativeTasks()
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ for _, t := range tasks {
+ if t.ID == id {
+ resp = widgetDetailResponse{Title: t.Content, Description: t.Description, Editable: true}
+ break
+ }
+ }
+ case "gtasks":
+ if t, ok := h.findGoogleTask(id); ok {
+ resp = widgetDetailResponse{Title: t.Title, Description: t.Notes, Editable: h.googleTasksClient != nil}
+ }
+ case "trello":
+ if c, ok := h.findCard(id); ok {
+ resp = widgetDetailResponse{Title: c.Name, Description: c.Description, Editable: h.trelloClient != nil}
+ }
+ default:
+ http.Error(w, "unsupported source", http.StatusBadRequest)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+type widgetUpdateRequest struct {
+ ID string `json:"id"`
+ Source string `json:"source"`
+ Description string `json:"description"`
+}
+
+// HandleWidgetUpdate saves an edited description from the widget's edit popup.
+func (h *Handler) HandleWidgetUpdate(w http.ResponseWriter, r *http.Request) {
+ var req widgetUpdateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+
+ switch req.Source {
+ case "doot":
+ if err := h.store.UpdateNativeTaskDescription(req.ID, req.Description); err != nil {
+ http.Error(w, "failed to update task", http.StatusInternalServerError)
+ return
+ }
+ case "gtasks":
+ if h.googleTasksClient == nil {
+ http.Error(w, "google tasks not configured", http.StatusServiceUnavailable)
+ return
+ }
+ t, ok := h.findGoogleTask(req.ID)
+ if !ok {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ if err := h.googleTasksClient.UpdateTaskNotes(r.Context(), t.ListID, req.ID, req.Description); err != nil {
+ http.Error(w, "failed to update task", http.StatusInternalServerError)
+ return
+ }
+ _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks)
+ case "trello":
+ if h.trelloClient == nil {
+ http.Error(w, "trello not configured", http.StatusServiceUnavailable)
+ return
+ }
+ if err := h.trelloClient.UpdateCard(r.Context(), req.ID, map[string]interface{}{"desc": req.Description}); err != nil {
+ http.Error(w, "failed to update task", http.StatusInternalServerError)
+ return
+ }
+ _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards)
+ default:
+ http.Error(w, "source not editable", http.StatusBadRequest)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
// HandleWidgetComplete proxies a task completion to the source API or native store.
func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) {
var req widgetCompleteRequest
@@ -187,6 +319,38 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) {
return
}
_ = h.store.SaveCompletedTask("doot", req.ID, "", nil)
+ case "gtasks":
+ if h.googleTasksClient == nil {
+ http.Error(w, "google tasks not configured", http.StatusServiceUnavailable)
+ return
+ }
+ t, ok := h.findGoogleTask(req.ID)
+ if !ok {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ if err := h.googleTasksClient.CompleteTask(r.Context(), t.ListID, req.ID); err != nil {
+ http.Error(w, "failed to complete task", http.StatusInternalServerError)
+ return
+ }
+ _ = h.store.SaveCompletedTask("gtasks", req.ID, t.Title, t.DueDate)
+ _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks)
+ case "trello":
+ if h.trelloClient == nil {
+ http.Error(w, "trello not configured", http.StatusServiceUnavailable)
+ return
+ }
+ c, ok := h.findCard(req.ID)
+ if !ok {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ if err := h.trelloClient.UpdateCard(r.Context(), req.ID, map[string]interface{}{"closed": true}); err != nil {
+ http.Error(w, "failed to complete task", http.StatusInternalServerError)
+ return
+ }
+ _ = h.store.SaveCompletedTask("trello", req.ID, c.Name, c.DueDate)
+ _ = h.store.DeleteCard(req.ID)
default:
http.Error(w, "source not completable", http.StatusBadRequest)
return
diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go
index 610a55c..3116918 100644
--- a/internal/handlers/widget_test.go
+++ b/internal/handlers/widget_test.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "context"
"encoding/json"
"fmt"
"net/http"
@@ -10,8 +11,65 @@ import (
"time"
"task-dashboard/internal/models"
+ "task-dashboard/internal/store"
)
+// mockGoogleTasksClient is a minimal api.GoogleTasksAPI stub for widget handler tests.
+type mockGoogleTasksClient struct {
+ completedListID, completedTaskID string
+ notesListID, notesTaskID, notes string
+}
+
+func (m *mockGoogleTasksClient) GetTasks(ctx context.Context) ([]models.GoogleTask, error) {
+ return nil, nil
+}
+func (m *mockGoogleTasksClient) GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error) {
+ return nil, nil
+}
+func (m *mockGoogleTasksClient) CompleteTask(ctx context.Context, listID, taskID string) error {
+ m.completedListID, m.completedTaskID = listID, taskID
+ return nil
+}
+func (m *mockGoogleTasksClient) UncompleteTask(ctx context.Context, listID, taskID string) error {
+ return nil
+}
+func (m *mockGoogleTasksClient) UpdateTaskNotes(ctx context.Context, listID, taskID, notes string) error {
+ m.notesListID, m.notesTaskID, m.notes = listID, taskID, notes
+ return nil
+}
+func (m *mockGoogleTasksClient) GetTaskLists(ctx context.Context) ([]models.TaskListInfo, error) {
+ return nil, nil
+}
+func (m *mockGoogleTasksClient) SetTaskListID(id string) {}
+
+// mockTrelloWidgetClient is a minimal api.TrelloAPI stub for widget handler tests
+// that records UpdateCard calls (the shared mockTrelloClient in handlers_test.go doesn't).
+type mockTrelloWidgetClient struct {
+ updatedCardID string
+ updates map[string]interface{}
+}
+
+func (m *mockTrelloWidgetClient) GetBoards(ctx context.Context) ([]models.Board, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) GetLists(ctx context.Context, boardID string) ([]models.List, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) {
+ return nil, nil
+}
+func (m *mockTrelloWidgetClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error {
+ m.updatedCardID = cardID
+ m.updates = updates
+ return nil
+}
+
func TestWidgetAuthMiddleware_NoToken(t *testing.T) {
called := false
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
@@ -295,6 +353,149 @@ func TestHandleWidgetComplete_NonCompletable(t *testing.T) {
}
}
+func TestHandleWidgetDetail_NativeTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ task := models.Task{ID: "t1", Content: "Buy milk", Description: "2%, not skim"}
+ if err := db.CreateNativeTask(task); err != nil {
+ t.Fatalf("failed to seed native task: %v", err)
+ }
+
+ h := &Handler{store: db}
+ req := httptest.NewRequest("GET", "/api/widget/detail?id=t1&source=doot", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp widgetDetailResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Title != "Buy milk" || resp.Description != "2%, not skim" || !resp.Editable {
+ t.Errorf("unexpected detail: %+v", resp)
+ }
+}
+
+func TestHandleWidgetDetail_UnsupportedSource(t *testing.T) {
+ h := &Handler{}
+ req := httptest.NewRequest("GET", "/api/widget/detail?id=x&source=calendar", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+}
+
+func TestHandleWidgetUpdate_NativeTask_PreservesContent(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ task := models.Task{ID: "t2", Content: "Original title", Description: "old desc"}
+ if err := db.CreateNativeTask(task); err != nil {
+ t.Fatalf("failed to seed native task: %v", err)
+ }
+
+ h := &Handler{store: db}
+ body := `{"id":"t2","source":"doot","description":"new desc"}`
+ req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ tasks, err := db.GetNativeTasks()
+ if err != nil {
+ t.Fatalf("failed to read back native tasks: %v", err)
+ }
+ if len(tasks) != 1 {
+ t.Fatalf("expected 1 native task, got %d", len(tasks))
+ }
+ if tasks[0].Content != "Original title" {
+ t.Errorf("title should be preserved, got %q", tasks[0].Content)
+ }
+ if tasks[0].Description != "new desc" {
+ t.Errorf("description should be updated, got %q", tasks[0].Description)
+ }
+}
+
+func TestHandleWidgetUpdate_UnsupportedSource(t *testing.T) {
+ h := &Handler{}
+ body := `{"id":"x","source":"calendar","description":"anything"}`
+ req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+}
+
+func TestTimelineItemToWidgetItem_GTask_Completable(t *testing.T) {
+ item := models.TimelineItem{
+ ID: "g1",
+ Title: "Renew passport",
+ Source: "gtasks",
+ Type: models.TimelineItemTypeGTask,
+ }
+
+ wi := TimelineItemToWidgetItem(item)
+
+ if wi.Type != "task" {
+ t.Errorf("Type: got %q, want %q", wi.Type, "task")
+ }
+ if !wi.Completable {
+ t.Error("gtask should be completable")
+ }
+}
+
+func TestTimelineItemToWidgetItem_Card_Completable(t *testing.T) {
+ item := models.TimelineItem{
+ ID: "c1",
+ Title: "Trello card",
+ Source: "trello",
+ Type: models.TimelineItemTypeCard,
+ }
+
+ wi := TimelineItemToWidgetItem(item)
+
+ if !wi.Completable {
+ t.Error("trello card should be completable via widget")
+ }
+}
+
+func TestHandleWidgetComplete_GoogleTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ if err := db.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", ListID: "list-a", UpdatedAt: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to seed google task: %v", err)
+ }
+
+ mock := &mockGoogleTasksClient{}
+ h := &Handler{store: db, googleTasksClient: mock}
+
+ body := `{"id":"g1","source":"gtasks"}`
+ req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if mock.completedListID != "list-a" || mock.completedTaskID != "g1" {
+ t.Errorf("unexpected complete call: listID=%q taskID=%q", mock.completedListID, mock.completedTaskID)
+ }
+}
+
// TestHandleWidgetComplete_UnknownID_Returns404 proves the 2026-07-12 fix at
// the handler layer: a "doot" completion for an id that doesn't exist must
// surface as 404, not the previous silent 200 (see
@@ -315,6 +516,166 @@ func TestHandleWidgetComplete_UnknownID_Returns404(t *testing.T) {
}
}
+func TestHandleWidgetComplete_GoogleTask_NotConfigured(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ h := &Handler{store: db}
+ body := `{"id":"g1","source":"gtasks"}`
+ req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req)
+
+ if w.Code != http.StatusServiceUnavailable {
+ t.Fatalf("expected 503, got %d", w.Code)
+ }
+}
+
+func TestHandleWidgetDetail_GoogleTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ if err := db.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", Notes: "bring photo", ListID: "list-a", UpdatedAt: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to seed google task: %v", err)
+ }
+
+ h := &Handler{store: db, googleTasksClient: &mockGoogleTasksClient{}}
+ req := httptest.NewRequest("GET", "/api/widget/detail?id=g1&source=gtasks", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp widgetDetailResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Title != "Renew passport" || resp.Description != "bring photo" || !resp.Editable {
+ t.Errorf("unexpected detail: %+v", resp)
+ }
+}
+
+func TestHandleWidgetUpdate_GoogleTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ if err := db.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", ListID: "list-a", UpdatedAt: time.Now()},
+ }); err != nil {
+ t.Fatalf("failed to seed google task: %v", err)
+ }
+
+ mock := &mockGoogleTasksClient{}
+ h := &Handler{store: db, googleTasksClient: mock}
+
+ body := `{"id":"g1","source":"gtasks","description":"bring photo and $170"}`
+ req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if mock.notesListID != "list-a" || mock.notesTaskID != "g1" || mock.notes != "bring photo and $170" {
+ t.Errorf("unexpected update call: listID=%q taskID=%q notes=%q", mock.notesListID, mock.notesTaskID, mock.notes)
+ }
+}
+
+func seedTestCard(t *testing.T, db *store.Store) {
+ t.Helper()
+ err := db.SaveBoards([]models.Board{
+ {
+ ID: "board1",
+ Name: "Test Board",
+ Cards: []models.Card{
+ {ID: "c1", Name: "Ship the widget", Description: "don't forget the checkbox", ListName: "Doing"},
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to seed card: %v", err)
+ }
+}
+
+func TestHandleWidgetDetail_TrelloCard(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ seedTestCard(t, db)
+
+ h := &Handler{store: db, trelloClient: &mockTrelloWidgetClient{}}
+ req := httptest.NewRequest("GET", "/api/widget/detail?id=c1&source=trello", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp widgetDetailResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Title != "Ship the widget" || resp.Description != "don't forget the checkbox" || !resp.Editable {
+ t.Errorf("unexpected detail: %+v", resp)
+ }
+}
+
+func TestHandleWidgetUpdate_TrelloCard(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ seedTestCard(t, db)
+
+ mock := &mockTrelloWidgetClient{}
+ h := &Handler{store: db, trelloClient: mock}
+
+ body := `{"id":"c1","source":"trello","description":"updated desc"}`
+ req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if mock.updatedCardID != "c1" || mock.updates["desc"] != "updated desc" {
+ t.Errorf("unexpected update call: id=%q updates=%v", mock.updatedCardID, mock.updates)
+ }
+}
+
+func TestHandleWidgetComplete_TrelloCard_Archives(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ seedTestCard(t, db)
+
+ mock := &mockTrelloWidgetClient{}
+ h := &Handler{store: db, trelloClient: mock}
+
+ body := `{"id":"c1","source":"trello"}`
+ req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if mock.updatedCardID != "c1" || mock.updates["closed"] != true {
+ t.Errorf("expected card to be archived (closed=true), got id=%q updates=%v", mock.updatedCardID, mock.updates)
+ }
+
+ boards, err := db.GetBoards()
+ if err != nil {
+ t.Fatalf("failed to read back boards: %v", err)
+ }
+ for _, b := range boards {
+ for _, c := range b.Cards {
+ if c.ID == "c1" {
+ t.Error("completed card should have been removed from local cache")
+ }
+ }
+ }
+}
+
// TestHandleWidgetAdd_CreatesTask proves the quick-add feature: POSTing a
// title to /api/widget/add creates an undated native task the same way the
// web UI's HandleUnifiedAdd does, but via the widget's bearer-token JSON
diff --git a/internal/models/types.go b/internal/models/types.go
index b3f3a22..6f8c405 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -109,13 +109,14 @@ type Board struct {
// Card represents a Trello card
type Card struct {
- ID string `json:"id"`
- Name string `json:"name"`
- ListID string `json:"list_id"`
- ListName string `json:"list_name"`
- BoardName string `json:"board_name"`
- DueDate *time.Time `json:"due_date,omitempty"`
- URL string `json:"url"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ListID string `json:"list_id"`
+ ListName string `json:"list_name"`
+ BoardName string `json:"board_name"`
+ DueDate *time.Time `json:"due_date,omitempty"`
+ URL string `json:"url"`
}
// Project represents a project (used for source config grouping)
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go
index f43a160..7789a0d 100644
--- a/internal/store/native_tasks.go
+++ b/internal/store/native_tasks.go
@@ -32,6 +32,9 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) {
}
// GetNativeTasksByDateRange returns non-completed native tasks due within the given range.
+// Overdue tasks (due before start) are deliberately excluded here -- BuildTimeline fetches
+// those separately via GetOverdueNativeTasks so callers that only want "in range" can use this
+// without double-counting against that separate fetch.
func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, error) {
rows, err := s.db.Query(`
SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at
@@ -100,6 +103,15 @@ func (s *Store) UpdateNativeTask(id, content, description string) error {
return err
}
+// UpdateNativeTaskDescription updates only a native task's description, leaving content untouched.
+func (s *Store) UpdateNativeTaskDescription(id, description string) error {
+ _, err := s.db.Exec(`
+ UPDATE native_tasks SET description = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ `, description, id)
+ return err
+}
+
// CompleteNativeTask marks a task as completed. Returns ErrNativeTaskNotFound
// if id doesn't match any row.
func (s *Store) CompleteNativeTask(id string) error {
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index f08e9bd..ad88166 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -292,8 +292,8 @@ func (s *Store) SaveBoards(boards []models.Board) error {
// Save cards
cardStmt, err := tx.Prepare(`
INSERT OR REPLACE INTO cards
- (id, name, board_id, list_id, list_name, due_date, url, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ (id, name, description, board_id, list_id, list_name, due_date, url, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`)
if err != nil {
return err
@@ -315,6 +315,7 @@ func (s *Store) SaveBoards(boards []models.Board) error {
_, err := cardStmt.Exec(
card.ID,
card.Name,
+ card.Description,
board.ID,
card.ListID,
card.ListName,
@@ -372,7 +373,7 @@ func (s *Store) GetBoards() ([]models.Board, error) {
// Fetch cards
cardRows, err := s.db.Query(`
- SELECT id, name, board_id, list_id, list_name, due_date, url
+ SELECT id, name, description, board_id, list_id, list_name, due_date, url
FROM cards
ORDER BY board_id, list_name, name
`)
@@ -389,6 +390,7 @@ func (s *Store) GetBoards() ([]models.Board, error) {
err := cardRows.Scan(
&card.ID,
&card.Name,
+ &card.Description,
&boardID,
&card.ListID,
&card.ListName,
@@ -696,22 +698,29 @@ func (s *Store) SaveGoogleTasks(tasks []models.GoogleTask) error {
return tx.Commit()
}
-// GetGoogleTasks retrieves all cached Google Tasks
-func (s *Store) GetGoogleTasks() ([]models.GoogleTask, error) {
- rows, err := s.db.Query(`
- SELECT id, title, notes, status, completed, due_date, updated_at, list_id, url
- FROM google_tasks
- ORDER BY completed ASC, CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC
- `)
- if err != nil {
- return nil, err
+// parseGoogleTaskTime parses a TEXT-column timestamp written by mattn/go-sqlite3's
+// default time.Time binding. The google_tasks.due_date/updated_at columns are declared
+// TEXT (not DATETIME), so the driver never auto-converts them back to time.Time on read.
+func parseGoogleTaskTime(s string) (time.Time, bool) {
+ for _, layout := range []string{
+ "2006-01-02 15:04:05.999999999-07:00",
+ time.RFC3339Nano,
+ time.RFC3339,
+ "2006-01-02 15:04:05",
+ "2006-01-02",
+ } {
+ if parsed, err := time.Parse(layout, s); err == nil {
+ return parsed, true
+ }
}
- defer func() { _ = rows.Close() }()
+ return time.Time{}, false
+}
+func scanGoogleTasks(rows *sql.Rows) ([]models.GoogleTask, error) {
var tasks []models.GoogleTask
for rows.Next() {
var t models.GoogleTask
- var dueDate, updatedAt sql.NullTime
+ var dueDate, updatedAt sql.NullString
err := rows.Scan(&t.ID, &t.Title, &t.Notes, &t.Status, &t.Completed, &dueDate, &updatedAt, &t.ListID, &t.URL)
if err != nil {
@@ -719,10 +728,14 @@ func (s *Store) GetGoogleTasks() ([]models.GoogleTask, error) {
}
if dueDate.Valid {
- t.DueDate = &dueDate.Time
+ if parsed, ok := parseGoogleTaskTime(dueDate.String); ok {
+ t.DueDate = &parsed
+ }
}
if updatedAt.Valid {
- t.UpdatedAt = updatedAt.Time
+ if parsed, ok := parseGoogleTaskTime(updatedAt.String); ok {
+ t.UpdatedAt = parsed
+ }
}
tasks = append(tasks, t)
@@ -731,40 +744,36 @@ func (s *Store) GetGoogleTasks() ([]models.GoogleTask, error) {
return tasks, rows.Err()
}
-// GetGoogleTasksByDateRange retrieves cached Google Tasks in a date range
-func (s *Store) GetGoogleTasksByDateRange(start, end time.Time) ([]models.GoogleTask, error) {
+// GetGoogleTasks retrieves all cached Google Tasks
+func (s *Store) GetGoogleTasks() ([]models.GoogleTask, error) {
rows, err := s.db.Query(`
SELECT id, title, notes, status, completed, due_date, updated_at, list_id, url
FROM google_tasks
- WHERE due_date IS NULL OR (due_date >= ? AND due_date < ?)
ORDER BY completed ASC, CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC
- `, start, end)
+ `)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
- var tasks []models.GoogleTask
- for rows.Next() {
- var t models.GoogleTask
- var dueDate, updatedAt sql.NullTime
-
- err := rows.Scan(&t.ID, &t.Title, &t.Notes, &t.Status, &t.Completed, &dueDate, &updatedAt, &t.ListID, &t.URL)
- if err != nil {
- return nil, err
- }
-
- if dueDate.Valid {
- t.DueDate = &dueDate.Time
- }
- if updatedAt.Valid {
- t.UpdatedAt = updatedAt.Time
- }
+ return scanGoogleTasks(rows)
+}
- tasks = append(tasks, t)
+// GetGoogleTasksByDateRange retrieves cached Google Tasks due before end, including overdue
+// tasks (due before start) so they keep appearing until completed.
+func (s *Store) GetGoogleTasksByDateRange(start, end time.Time) ([]models.GoogleTask, error) {
+ rows, err := s.db.Query(`
+ SELECT id, title, notes, status, completed, due_date, updated_at, list_id, url
+ FROM google_tasks
+ WHERE due_date IS NULL OR due_date < ?
+ ORDER BY completed ASC, CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC
+ `, end)
+ if err != nil {
+ return nil, err
}
+ defer func() { _ = rows.Close() }()
- return tasks, rows.Err()
+ return scanGoogleTasks(rows)
}
// Agent operations
diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go
index 2467f96..e8af436 100644
--- a/internal/store/sqlite_test.go
+++ b/internal/store/sqlite_test.go
@@ -103,6 +103,7 @@ func setupTestStoreWithCards(t *testing.T) *Store {
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
+ description TEXT DEFAULT '',
board_id TEXT NOT NULL,
list_id TEXT,
list_name TEXT,
@@ -118,6 +119,227 @@ func setupTestStoreWithCards(t *testing.T) *Store {
return store
}
+func setupTestStoreWithGoogleTasks(t *testing.T) *Store {
+ t.Helper()
+
+ tempDir := t.TempDir()
+ dbPath := filepath.Join(tempDir, "test.db")
+
+ db, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ t.Fatalf("Failed to open test database: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+
+ store := &Store{db: db}
+
+ schema := `
+ CREATE TABLE IF NOT EXISTS google_tasks (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ notes TEXT,
+ status TEXT NOT NULL,
+ completed BOOLEAN NOT NULL DEFAULT 0,
+ due_date TEXT,
+ updated_at TEXT,
+ list_id TEXT NOT NULL,
+ url TEXT
+ );
+ `
+ if _, err := db.Exec(schema); err != nil {
+ t.Fatalf("Failed to create schema: %v", err)
+ }
+
+ return store
+}
+
+func setupTestStoreWithNativeTasks(t *testing.T) *Store {
+ t.Helper()
+
+ tempDir := t.TempDir()
+ dbPath := filepath.Join(tempDir, "test.db")
+
+ db, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ t.Fatalf("Failed to open test database: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+
+ store := &Store{db: db}
+
+ schema := `
+ CREATE TABLE IF NOT EXISTS native_tasks (
+ id TEXT PRIMARY KEY,
+ content TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ project_name TEXT DEFAULT '',
+ due_date DATETIME,
+ priority INTEGER DEFAULT 1,
+ completed BOOLEAN DEFAULT 0,
+ labels TEXT DEFAULT '[]',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ `
+ if _, err := db.Exec(schema); err != nil {
+ t.Fatalf("Failed to create schema: %v", err)
+ }
+
+ return store
+}
+
+// TestGetNativeTasksByDateRange_ExcludesOverdue documents the deliberate contract after
+// 2026-07-13's reconciliation: GetNativeTasksByDateRange is scoped to [start, end) only.
+// Overdue tasks (due before start) are BuildTimeline's job to fetch separately via
+// GetOverdueNativeTasks -- see that test below and timeline_logic.go's "6." section --
+// so this function must NOT also return them, or BuildTimeline would double them up.
+func TestGetNativeTasksByDateRange_ExcludesOverdue(t *testing.T) {
+ store := setupTestStoreWithNativeTasks(t)
+
+ now := time.Now()
+ overdue := now.Add(-48 * time.Hour)
+ today := now
+ future := now.Add(72 * time.Hour) // outside the window
+
+ for _, task := range []models.Task{
+ {ID: "t-overdue", Content: "Overdue task", DueDate: &overdue},
+ {ID: "t-today", Content: "Today task", DueDate: &today},
+ {ID: "t-future", Content: "Future task", DueDate: &future},
+ } {
+ if err := store.CreateNativeTask(task); err != nil {
+ t.Fatalf("CreateNativeTask(%s) failed: %v", task.ID, err)
+ }
+ }
+
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ end := start.Add(48 * time.Hour)
+
+ results, err := store.GetNativeTasksByDateRange(start, end)
+ if err != nil {
+ t.Fatalf("GetNativeTasksByDateRange failed: %v", err)
+ }
+
+ ids := make(map[string]bool)
+ for _, r := range results {
+ ids[r.ID] = true
+ }
+ if ids["t-overdue"] {
+ t.Error("expected overdue task to be excluded from the ranged fetch")
+ }
+ if !ids["t-today"] {
+ t.Error("expected today's task to be included")
+ }
+ if ids["t-future"] {
+ t.Error("expected far-future task to be excluded")
+ }
+}
+
+// TestGetOverdueNativeTasks_IncludesOnlyPastDue is the store-level counterpart to
+// TestGetNativeTasksByDateRange_ExcludesOverdue: this is the function BuildTimeline relies on
+// to actually surface overdue tasks (see timeline_logic.go's "6." section and
+// TestBuildTimeline_IncludesOverdueNativeTasks for the integration-level proof).
+func TestGetOverdueNativeTasks_IncludesOnlyPastDue(t *testing.T) {
+ store := setupTestStoreWithNativeTasks(t)
+
+ now := time.Now()
+ overdue := now.Add(-48 * time.Hour)
+ today := now
+
+ for _, task := range []models.Task{
+ {ID: "t-overdue", Content: "Overdue task", DueDate: &overdue},
+ {ID: "t-today", Content: "Today task", DueDate: &today},
+ } {
+ if err := store.CreateNativeTask(task); err != nil {
+ t.Fatalf("CreateNativeTask(%s) failed: %v", task.ID, err)
+ }
+ }
+
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+
+ results, err := store.GetOverdueNativeTasks(start)
+ if err != nil {
+ t.Fatalf("GetOverdueNativeTasks failed: %v", err)
+ }
+ if len(results) != 1 || results[0].ID != "t-overdue" {
+ t.Errorf("expected only the overdue task, got %+v", results)
+ }
+}
+
+// TestSaveAndGetGoogleTasks_RoundTripsTimestamps guards against a regression where
+// due_date/updated_at (TEXT columns, not DATETIME) failed to scan back into time.Time
+// via sql.NullTime whenever a row had a non-null timestamp.
+func TestSaveAndGetGoogleTasks_RoundTripsTimestamps(t *testing.T) {
+ store := setupTestStoreWithGoogleTasks(t)
+
+ due := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
+ updated := time.Now()
+
+ err := store.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g1", Title: "Renew passport", Notes: "bring photo", Status: "needsAction", ListID: "list-a", DueDate: &due, UpdatedAt: updated},
+ })
+ if err != nil {
+ t.Fatalf("SaveGoogleTasks failed: %v", err)
+ }
+
+ tasks, err := store.GetGoogleTasks()
+ if err != nil {
+ t.Fatalf("GetGoogleTasks failed: %v", err)
+ }
+ if len(tasks) != 1 {
+ t.Fatalf("expected 1 task, got %d", len(tasks))
+ }
+ got := tasks[0]
+ if got.DueDate == nil || !got.DueDate.Equal(due) {
+ t.Errorf("DueDate: got %v, want %v", got.DueDate, due)
+ }
+ if got.UpdatedAt.IsZero() {
+ t.Error("UpdatedAt should have round-tripped, got zero value")
+ }
+}
+
+// TestGetGoogleTasksByDateRange_IncludesOverdue guards against a regression where a task due
+// before the window's start (e.g. yesterday, still incomplete) silently dropped out of the
+// widget/timeline the moment the day rolled over, because the query required due_date >= start.
+func TestGetGoogleTasksByDateRange_IncludesOverdue(t *testing.T) {
+ store := setupTestStoreWithGoogleTasks(t)
+
+ now := time.Now()
+ overdue := now.Add(-48 * time.Hour)
+ today := now
+ future := now.Add(72 * time.Hour) // outside the window
+
+ err := store.SaveGoogleTasks([]models.GoogleTask{
+ {ID: "g-overdue", Title: "Overdue task", ListID: "list-a", DueDate: &overdue, UpdatedAt: now},
+ {ID: "g-today", Title: "Today task", ListID: "list-a", DueDate: &today, UpdatedAt: now},
+ {ID: "g-future", Title: "Future task", ListID: "list-a", DueDate: &future, UpdatedAt: now},
+ })
+ if err != nil {
+ t.Fatalf("SaveGoogleTasks failed: %v", err)
+ }
+
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ end := start.Add(48 * time.Hour)
+
+ results, err := store.GetGoogleTasksByDateRange(start, end)
+ if err != nil {
+ t.Fatalf("GetGoogleTasksByDateRange failed: %v", err)
+ }
+
+ ids := make(map[string]bool)
+ for _, r := range results {
+ ids[r.ID] = true
+ }
+ if !ids["g-overdue"] {
+ t.Error("expected overdue task to be included, but it was excluded")
+ }
+ if !ids["g-today"] {
+ t.Error("expected today's task to be included")
+ }
+ if ids["g-future"] {
+ t.Error("expected far-future task to be excluded")
+ }
+}
+
// setupTestStoreWithMeals creates a test store with meals table
func setupTestStoreWithMeals(t *testing.T) *Store {
t.Helper()