summaryrefslogtreecommitdiff
path: root/internal/store
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-13 06:54:13 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-13 06:54:13 +0000
commit8310f802dd9fc6ef5dff0be7f640f79c5b39987f (patch)
treed145bd7be5801f12563e99b6828b0f2facdc370c /internal/store
parent73fbd05a1230552bcef9834d3ee999ef19957693 (diff)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/native_tasks.go16
-rw-r--r--internal/store/sqlite.go85
-rw-r--r--internal/store/sqlite_test.go189
3 files changed, 249 insertions, 41 deletions
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()