summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-14 20:23:18 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 02:44:32 +0000
commit550ffde0d8b71be96a4d21d7c07b718f367c2c48 (patch)
treeef21db975b1504ffe712f992181d616a511be1c5 /internal
parentc99d52ccf5c52e9c75b6fcb661c8e74816b34924 (diff)
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.
Diffstat (limited to 'internal')
-rw-r--r--internal/store/native_tasks.go150
-rw-r--r--internal/store/native_tasks_test.go190
2 files changed, 337 insertions, 3 deletions
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)
+ }
+}