diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/google_tasks.go | 12 | ||||
| -rw-r--r-- | internal/api/interfaces.go | 1 | ||||
| -rw-r--r-- | internal/api/trello.go | 13 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 6 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 1 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 168 | ||||
| -rw-r--r-- | internal/handlers/widget_test.go | 361 | ||||
| -rw-r--r-- | internal/models/types.go | 15 | ||||
| -rw-r--r-- | internal/store/native_tasks.go | 12 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 85 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 222 |
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() |
