From 550ffde0d8b71be96a4d21d7c07b718f367c2c48 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 14 Jul 2026 20:23:18 +0000 Subject: feat(tasks): create the next recurring iteration on completion CompleteNativeTask now creates a new row for the next occurrence when completing the latest iteration of a recurring series (using the one-shot next_occurrence_override if set, else ComputeNextOccurrence). Completing an already-superseded row (the periodic due-check beat it to creating the successor) just marks it completed, no double-create. --- internal/store/native_tasks.go | 150 +++++++++++++++++++++++++++- internal/store/native_tasks_test.go | 190 ++++++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 3 deletions(-) (limited to 'internal/store') diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index c0a7a61..501c0bc 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -1,9 +1,11 @@ package store import ( + "crypto/rand" "database/sql" "encoding/json" "errors" + "fmt" "strconv" "strings" "time" @@ -90,6 +92,28 @@ func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) { return scanNativeTasks(rows) } +// GetNativeTaskByID returns a single native task by id, or ErrNativeTaskNotFound. +func (s *Store) GetNativeTaskByID(id string) (*models.Task, error) { + rows, err := s.db.Query(` + SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at, + recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override + FROM native_tasks + WHERE id = ? + `, id) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + tasks, err := scanNativeTasks(rows) + if err != nil { + return nil, err + } + if len(tasks) == 0 { + return nil, ErrNativeTaskNotFound + } + return &tasks[0], nil +} + // CreateNativeTask inserts a new native task. func (s *Store) CreateNativeTask(task models.Task) error { labelsJSON, _ := json.Marshal(task.Labels) @@ -118,16 +142,57 @@ func (s *Store) UpdateNativeTaskDescription(id, description string) error { return err } -// CompleteNativeTask marks a task as completed. Returns ErrNativeTaskNotFound -// if id doesn't match any row. +// CompleteNativeTask marks a task as completed. If it's the latest +// occurrence of a recurring series (no newer row exists yet), it also +// creates the next iteration. Returns ErrNativeTaskNotFound if id doesn't +// match any row. func (s *Store) CompleteNativeTask(id string) error { + task, err := s.GetNativeTaskByID(id) + if err != nil { + return err + } + result, err := s.db.Exec(` UPDATE native_tasks SET completed = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, id) if err != nil { return err } - return checkRowsAffected(result) + if err := checkRowsAffected(result); err != nil { + return err + } + + if task.RecurrenceSeriesID == "" { + return nil + } + isLatest, err := s.isLatestInSeries(*task) + if err != nil { + return err + } + if !isLatest { + return nil + } + return s.CreateNextIteration(*task) +} + +// isLatestInSeries reports whether task is the row with the latest +// due_date in its recurrence series (i.e., no newer iteration has been +// created yet). Ties on due_date are broken by created_at: the +// more-recently-created row wins, so CreateNextIteration's freshly-inserted +// row always displaces the row it was generated from, never the reverse. +func (s *Store) isLatestInSeries(task models.Task) (bool, error) { + var exists bool + err := s.db.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM native_tasks + WHERE recurrence_series_id = ? + AND (due_date > ? OR (due_date = ? AND created_at > ?)) + ) + `, task.RecurrenceSeriesID, task.DueDate, task.DueDate, task.CreatedAt).Scan(&exists) + if err != nil { + return false, err + } + return !exists, nil } // RescheduleNativeTask sets a new due date on a task. Returns @@ -154,6 +219,85 @@ func (s *Store) UncompleteNativeTask(id string) error { return checkRowsAffected(result) } +// CreateNextIteration copies old's content, description, project_name, +// priority, labels, and recurrence fields onto a brand-new row (new id, +// same recurrence_series_id, completed=false, next_occurrence_override=""), +// with due_date set to old.NextOccurrenceOverride if present, else +// ComputeNextOccurrence(old.DueDate, ...). old itself is left untouched. +func (s *Store) CreateNextIteration(old models.Task) error { + var nextDue *time.Time + switch { + case old.NextOccurrenceOverride != nil: + nextDue = old.NextOccurrenceOverride + case old.DueDate != nil: + computed := models.ComputeNextOccurrence(*old.DueDate, old.RecurrenceFreq, old.RecurrenceInterval, old.RecurrenceWeekdays) + nextDue = &computed + } + + labelsJSON, _ := json.Marshal(old.Labels) + _, err := s.db.Exec(` + INSERT INTO native_tasks ( + id, content, description, project_name, due_date, priority, labels, + recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, newTaskID(), old.Content, old.Description, old.ProjectName, nextDue, old.Priority, string(labelsJSON), + old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID) + return err +} + +// newTaskID generates a random hex id for a new native_tasks row -- the +// same format as handlers.newID(), duplicated here since store must not +// import handlers. +func newTaskID() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return fmt.Sprintf("%x", b) +} + +// SetTaskRecurrence sets or clears a task's recurrence pattern. freq == "" +// clears the pattern (recurrence_series_id is left untouched so history +// stays linkable -- a cleared task just stops generating new iterations). +// Setting a freq for the first time (existing recurrence_series_id is +// empty) generates a new series id. Returns ErrNativeTaskNotFound if id +// doesn't match any row. +func (s *Store) SetTaskRecurrence(id, freq string, interval int, weekdays []int) error { + task, err := s.GetNativeTaskByID(id) + if err != nil { + return err + } + + seriesID := task.RecurrenceSeriesID + if freq != "" && seriesID == "" { + seriesID = newTaskID() + } + + result, err := s.db.Exec(` + UPDATE native_tasks + SET recurrence_freq = ?, recurrence_interval = ?, recurrence_weekdays = ?, recurrence_series_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, freq, interval, formatWeekdays(weekdays), seriesID, id) + if err != nil { + return err + } + return checkRowsAffected(result) +} + +// SetNextOccurrenceOverride sets a one-shot override for a task's next +// occurrence, consumed (read, but not explicitly cleared -- the override +// column simply isn't copied onto the new row) the next time +// CreateNextIteration runs for its series. Returns ErrNativeTaskNotFound if +// id doesn't match any row. +func (s *Store) SetNextOccurrenceOverride(id string, date time.Time) error { + result, err := s.db.Exec(` + UPDATE native_tasks SET next_occurrence_override = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, date.Format("2006-01-02"), id) + 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" diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go index 0a87bfa..fae7e90 100644 --- a/internal/store/native_tasks_test.go +++ b/internal/store/native_tasks_test.go @@ -168,3 +168,193 @@ func TestGetNativeTasks_NonRecurringTask_HasEmptyRecurrenceFields(t *testing.T) t.Errorf("expected nil NextOccurrenceOverride for non-recurring task, got %v", found.NextOccurrenceOverride) } } + +func TestGetNativeTaskByID_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + _, err := s.GetNativeTaskByID("does-not-exist") + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) + } +} + +func TestGetNativeTaskByID_RealID_ReturnsTask(t *testing.T) { + s := newNativeTasksTestStore(t) + + task, err := s.GetNativeTaskByID("real-1") + if err != nil { + t.Fatalf("GetNativeTaskByID: %v", err) + } + if task.Content != "Real task" { + t.Errorf("Content = %q, want %q", task.Content, "Real task") + } +} + +func TestCompleteNativeTask_NonRecurring_JustCompletes(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.CompleteNativeTask("real-1"); err != nil { + t.Fatalf("CompleteNativeTask: %v", err) + } + + tasks, err := s.db.Query(`SELECT id FROM native_tasks`) + if err != nil { + t.Fatal(err) + } + defer tasks.Close() + count := 0 + for tasks.Next() { + count++ + } + if count != 1 { + t.Errorf("expected exactly 1 row (no iteration created for a non-recurring task), got %d", count) + } +} + +func TestCompleteNativeTask_LatestInSeries_CreatesNextIteration(t *testing.T) { + s := newNativeTasksTestStore(t) + if _, err := s.db.Exec(` + INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) + VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') + `); err != nil { + t.Fatal(err) + } + + if err := s.CompleteNativeTask("rec-1"); err != nil { + t.Fatalf("CompleteNativeTask: %v", err) + } + + var completed bool + if err := s.db.QueryRow(`SELECT completed FROM native_tasks WHERE id = 'rec-1'`).Scan(&completed); err != nil { + t.Fatal(err) + } + if !completed { + t.Error("expected rec-1 to be marked completed") + } + + var nextCount int + var nextDue string + if err := s.db.QueryRow(` + SELECT COUNT(*), COALESCE(MAX(due_date), '') FROM native_tasks + WHERE recurrence_series_id = 'series-1' AND id != 'rec-1' + `).Scan(&nextCount, &nextDue); err != nil { + t.Fatal(err) + } + if nextCount != 1 { + t.Fatalf("expected exactly 1 new iteration, got %d", nextCount) + } + if nextDue[:10] != "2026-07-20" { + t.Errorf("next iteration due_date = %q, want 2026-07-20", nextDue) + } +} + +func TestCompleteNativeTask_UsesNextOccurrenceOverride(t *testing.T) { + s := newNativeTasksTestStore(t) + if _, err := s.db.Exec(` + INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id, next_occurrence_override) + VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1', '2026-08-01') + `); err != nil { + t.Fatal(err) + } + + if err := s.CompleteNativeTask("rec-1"); err != nil { + t.Fatalf("CompleteNativeTask: %v", err) + } + + var nextDue string + if err := s.db.QueryRow(` + SELECT due_date FROM native_tasks WHERE recurrence_series_id = 'series-1' AND id != 'rec-1' + `).Scan(&nextDue); err != nil { + t.Fatal(err) + } + if nextDue[:10] != "2026-08-01" { + t.Errorf("next iteration due_date = %q, want 2026-08-01 (the override)", nextDue) + } +} + +func TestCompleteNativeTask_AlreadySuperseded_DoesNotDoubleCreate(t *testing.T) { + s := newNativeTasksTestStore(t) + if _, err := s.db.Exec(` + INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) + VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') + `); err != nil { + t.Fatal(err) + } + // Simulate the periodic due-check having already created the successor + // before the user got around to completing rec-1. + if _, err := s.db.Exec(` + INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) + VALUES ('rec-2', 'Water plants', '2026-07-20', 'weekly', 1, 'series-1') + `); err != nil { + t.Fatal(err) + } + + if err := s.CompleteNativeTask("rec-1"); err != nil { + t.Fatalf("CompleteNativeTask: %v", err) + } + + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE recurrence_series_id = 'series-1'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 2 { + t.Errorf("expected still exactly 2 rows in the series (no double-create), got %d", count) + } +} + +func TestSetTaskRecurrence_FirstTimeGeneratesSeriesID(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.SetTaskRecurrence("real-1", "weekly", 1, []int{1, 3}); err != nil { + t.Fatalf("SetTaskRecurrence: %v", err) + } + + task, err := s.GetNativeTaskByID("real-1") + if err != nil { + t.Fatal(err) + } + if task.RecurrenceFreq != "weekly" { + t.Errorf("RecurrenceFreq = %q, want weekly", task.RecurrenceFreq) + } + if task.RecurrenceSeriesID == "" { + t.Error("expected a generated RecurrenceSeriesID, got empty string") + } + if len(task.RecurrenceWeekdays) != 2 || task.RecurrenceWeekdays[0] != 1 || task.RecurrenceWeekdays[1] != 3 { + t.Errorf("RecurrenceWeekdays = %v, want [1 3]", task.RecurrenceWeekdays) + } +} + +func TestSetTaskRecurrence_ClearingKeepsSeriesID(t *testing.T) { + s := newNativeTasksTestStore(t) + if err := s.SetTaskRecurrence("real-1", "weekly", 1, nil); err != nil { + t.Fatal(err) + } + task, err := s.GetNativeTaskByID("real-1") + if err != nil { + t.Fatal(err) + } + seriesID := task.RecurrenceSeriesID + + if err := s.SetTaskRecurrence("real-1", "", 1, nil); err != nil { + t.Fatalf("SetTaskRecurrence (clear): %v", err) + } + + task, err = s.GetNativeTaskByID("real-1") + if err != nil { + t.Fatal(err) + } + if task.RecurrenceFreq != "" { + t.Errorf("RecurrenceFreq = %q, want empty after clearing", task.RecurrenceFreq) + } + if task.RecurrenceSeriesID != seriesID { + t.Errorf("RecurrenceSeriesID = %q, want unchanged %q after clearing", task.RecurrenceSeriesID, seriesID) + } +} + +func TestSetNextOccurrenceOverride_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + err := s.SetNextOccurrenceOverride("does-not-exist", time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)) + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) + } +} -- cgit v1.2.3