summaryrefslogtreecommitdiff
path: root/internal/store
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/native_tasks.go88
-rw-r--r--internal/store/native_tasks_test.go96
-rw-r--r--internal/store/sqlite.go10
-rw-r--r--internal/store/sqlite_test.go45
4 files changed, 215 insertions, 24 deletions
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go
index 218675e..7789a0d 100644
--- a/internal/store/native_tasks.go
+++ b/internal/store/native_tasks.go
@@ -1,12 +1,21 @@
package store
import (
+ "database/sql"
"encoding/json"
+ "errors"
"time"
"task-dashboard/internal/models"
)
+// ErrNativeTaskNotFound is returned by CompleteNativeTask, UncompleteNativeTask,
+// and RescheduleNativeTask when no row matches the given id -- previously these
+// three silently reported success on a 0-row UPDATE (Exec's err is nil even when
+// no rows match), so a stale or wrong id from a caller looked identical to a real
+// completion: the HTTP response was 200, but nothing in the database changed.
+var ErrNativeTaskNotFound = errors.New("native task not found")
+
// GetNativeTasks returns all non-completed native tasks.
func (s *Store) GetNativeTasks() ([]models.Task, error) {
rows, err := s.db.Query(`
@@ -22,15 +31,37 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) {
return scanNativeTasks(rows)
}
-// GetNativeTasksByDateRange returns non-completed native tasks due within the given range,
-// including overdue tasks (due before start) so they keep appearing until completed.
+// 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
FROM native_tasks
+ WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ?
+ ORDER BY due_date ASC, priority DESC
+ `, start, end)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ return scanNativeTasks(rows)
+}
+
+// GetOverdueNativeTasks returns non-completed native tasks whose due date is
+// before the given time. BuildTimeline calls this alongside
+// GetNativeTasksByDateRange, whose lower bound excludes anything due before
+// the requested range's start -- without this, a task overdue from a
+// previous day never gets fetched at all, so it never reaches
+// ComputeDaySection to be marked IsOverdue.
+func (s *Store) GetOverdueNativeTasks(before 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 < ?
ORDER BY due_date ASC, priority DESC
- `, end)
+ `, before)
if err != nil {
return nil, err
}
@@ -81,31 +112,62 @@ func (s *Store) UpdateNativeTaskDescription(id, description string) error {
return err
}
-// CompleteNativeTask marks a task as completed.
+// CompleteNativeTask marks a task as completed. Returns ErrNativeTaskNotFound
+// if id doesn't match any row.
func (s *Store) CompleteNativeTask(id string) error {
- _, err := s.db.Exec(`
+ result, err := s.db.Exec(`
UPDATE native_tasks SET completed = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`, id)
- return err
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
}
-// RescheduleNativeTask sets a new due date on a task.
+// RescheduleNativeTask sets a new due date on a task. Returns
+// ErrNativeTaskNotFound if id doesn't match any row.
func (s *Store) RescheduleNativeTask(id string, dueDate time.Time) error {
- _, err := s.db.Exec(`
+ result, err := s.db.Exec(`
UPDATE native_tasks SET due_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`, dueDate, id)
- return err
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
}
-// UncompleteNativeTask marks a task as not completed.
+// UncompleteNativeTask marks a task as not completed. Returns
+// ErrNativeTaskNotFound if id doesn't match any row.
func (s *Store) UncompleteNativeTask(id string) error {
- _, err := s.db.Exec(`
+ result, err := s.db.Exec(`
UPDATE native_tasks SET completed = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`, id)
- return err
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+
+// checkRowsAffected returns ErrNativeTaskNotFound if the update matched no
+// rows -- mirrors the RowsAffected() check already used in sqlite.go's
+// ApproveAgentSession/DenyAgentSession for the same "silent 0-row update"
+// class of bug.
+func checkRowsAffected(result sql.Result) error {
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return ErrNativeTaskNotFound
+ }
+ return nil
}
-func scanNativeTasks(rows interface{ Next() bool; Scan(...interface{}) error; Err() error }) ([]models.Task, error) {
+func scanNativeTasks(rows interface {
+ Next() bool
+ Scan(...interface{}) error
+ Err() error
+}) ([]models.Task, error) {
var tasks []models.Task
for rows.Next() {
var t models.Task
diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go
new file mode 100644
index 0000000..5c11d3a
--- /dev/null
+++ b/internal/store/native_tasks_test.go
@@ -0,0 +1,96 @@
+package store
+
+import (
+ "database/sql"
+ "errors"
+ "path/filepath"
+ "testing"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+// newNativeTasksTestStore creates a Store backed by a fresh temp sqlite DB
+// with just the native_tasks table -- enough to exercise
+// CompleteNativeTask/UncompleteNativeTask/RescheduleNativeTask without
+// running the full migration set.
+func newNativeTasksTestStore(t *testing.T) *Store {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "test.db")
+ db, err := sql.Open("sqlite3", dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := db.Exec(`
+ CREATE TABLE 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
+ )
+ `); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`INSERT INTO native_tasks (id, content) VALUES ('real-1', 'Real task')`); err != nil {
+ t.Fatal(err)
+ }
+ return &Store{db: db}
+}
+
+// TestCompleteNativeTask_UnknownID_ReturnsErrNotFound proves the 2026-07-12
+// fix: a plain UPDATE ... WHERE id = ? silently "succeeds" with a nil error
+// when 0 rows match (this is how database/sql's Exec behaves for an UPDATE
+// that matches nothing -- no error, just RowsAffected() == 0). Before this
+// fix, CompleteNativeTask returned that nil error straight through, so a
+// stale/wrong id from a caller (the Android widget, in the real incident
+// this was found from) looked identical to a real completion: HTTP 200,
+// nothing changed in the database.
+func TestCompleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ err := s.CompleteNativeTask("does-not-exist")
+ if !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Fatalf("expected ErrNativeTaskNotFound, got %v", err)
+ }
+}
+
+func TestCompleteNativeTask_RealID_Succeeds(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if err := s.CompleteNativeTask("real-1"); err != nil {
+ t.Fatalf("CompleteNativeTask: %v", err)
+ }
+
+ var completed bool
+ if err := s.db.QueryRow(`SELECT completed FROM native_tasks WHERE id = 'real-1'`).Scan(&completed); err != nil {
+ t.Fatal(err)
+ }
+ if !completed {
+ t.Error("expected task to be marked completed")
+ }
+}
+
+func TestUncompleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ err := s.UncompleteNativeTask("does-not-exist")
+ if !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Fatalf("expected ErrNativeTaskNotFound, got %v", err)
+ }
+}
+
+func TestRescheduleNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ err := s.RescheduleNativeTask("does-not-exist", time.Now())
+ if !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Fatalf("expected ErrNativeTaskNotFound, got %v", err)
+ }
+}
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index f955f71..ad88166 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -600,8 +600,8 @@ func (s *Store) SaveCalendarEvents(events []models.CalendarEvent) error {
}
stmt, err := tx.Prepare(`
- INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link)
- VALUES (?, ?, ?, ?, ?, ?)
+ INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link, recurring_event_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return err
@@ -609,7 +609,7 @@ func (s *Store) SaveCalendarEvents(events []models.CalendarEvent) error {
defer func() { _ = stmt.Close() }()
for _, e := range events {
- _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink)
+ _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink, e.RecurringEventID)
if err != nil {
return err
}
@@ -644,7 +644,7 @@ func (s *Store) GetCalendarEvents() ([]models.CalendarEvent, error) {
// GetCalendarEventsByDateRange retrieves cached calendar events within a date range
func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.CalendarEvent, error) {
rows, err := s.db.Query(`
- SELECT id, summary, description, start_time, end_time, html_link
+ SELECT id, summary, description, start_time, end_time, html_link, recurring_event_id
FROM calendar_events
WHERE start_time >= ? AND start_time <= ?
ORDER BY start_time ASC
@@ -657,7 +657,7 @@ func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.Cal
var events []models.CalendarEvent
for rows.Next() {
var e models.CalendarEvent
- if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink); err != nil {
+ if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink, &e.RecurringEventID); err != nil {
return nil, err
}
events = append(events, e)
diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go
index 4d3c8f8..e8af436 100644
--- a/internal/store/sqlite_test.go
+++ b/internal/store/sqlite_test.go
@@ -188,10 +188,12 @@ func setupTestStoreWithNativeTasks(t *testing.T) *Store {
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) {
+// 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()
@@ -221,8 +223,8 @@ func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) {
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-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")
@@ -232,6 +234,37 @@ func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) {
}
}
+// 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.