summaryrefslogtreecommitdiff
path: root/internal/store/native_tasks.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store/native_tasks.go')
-rw-r--r--internal/store/native_tasks.go150
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"