diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-14 20:23:18 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-16 02:44:32 +0000 |
| commit | 550ffde0d8b71be96a4d21d7c07b718f367c2c48 (patch) | |
| tree | ef21db975b1504ffe712f992181d616a511be1c5 /internal/store/native_tasks.go | |
| parent | c99d52ccf5c52e9c75b6fcb661c8e74816b34924 (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/store/native_tasks.go')
| -rw-r--r-- | internal/store/native_tasks.go | 150 |
1 files changed, 147 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" |
