From 8310f802dd9fc6ef5dff0be7f640f79c5b39987f Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 13 Jul 2026 06:54:13 +0000 Subject: feat(widget): editable task details, scrollable list, and overdue-task fixes - Add description editing to the widget's task detail popup for doot/gtasks/trello, backed by new GET /api/widget/detail and POST /api/widget/update endpoints - Make Google Tasks and Trello cards completable via the widget (Trello completion archives the card); fix Trello description never being fetched, which meant saving could silently wipe a card's real desc - Fix google_tasks.due_date/updated_at (TEXT columns) never round-tripping through sql.NullTime, which broke cached Google Tasks reads whenever the cache was valid - Fix native-task and Google-Task date-range queries excluding anything due before the window start, which dropped incomplete tasks off the widget the moment their due day passed (the "overdue tasks disappeared" bug) - Fix native task description edits blanking the task's title - Make the widget's day list scroll (LazyColumn) instead of clipping - Optimistically remove a task from the widget immediately on completion, ahead of the authoritative background refresh Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL --- internal/store/native_tasks.go | 16 +++- internal/store/sqlite.go | 85 +++++++++--------- internal/store/sqlite_test.go | 189 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 41 deletions(-) (limited to 'internal/store') diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index 5758407..218675e 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -22,14 +22,15 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) { return scanNativeTasks(rows) } -// GetNativeTasksByDateRange returns non-completed native tasks due within the given range. +// GetNativeTasksByDateRange returns non-completed native tasks due within the given range, +// including overdue tasks (due before start) so they keep appearing until completed. 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 FROM native_tasks - WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ? + WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ? ORDER BY due_date ASC, priority DESC - `, start, end) + `, end) if err != nil { return nil, err } @@ -71,6 +72,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. func (s *Store) CompleteNativeTask(id string) error { _, err := s.db.Exec(` diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index a6e9fd4..f955f71 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..4d3c8f8 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,194 @@ 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_IncludesOverdue guards against a regression where a native 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 TestGetNativeTasksByDateRange_IncludesOverdue(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 included, but it was excluded") + } + 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") + } +} + +// 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() -- cgit v1.2.3