feat(tasks): repeating tasks (daily, weekly, monthly, yearly)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s

- recurrence on tasks, set in the detail dialog; 🔁 on rows and Kanban cards
- Completing a repeating task (detail, list, Kanban drag to done, note
  checkbox) creates the next one: title, tags, priority, notes, list,
  assignees and rule copied, subtasks reset, first open column, due date
  one interval later (month end clamped); pushed live as TASK_CREATED
- Each task spawns at most once (re-open + complete makes no duplicate)
- Export/import keep the rule (Obsidian Tasks "🔁 every week", JSON field)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 07:23:09 +01:00
1 parent 0e9fd1b3ad
commit 588c679c6a
16 files changed
+376 -19

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
package storage
import (
"errors"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
// ErrInvalidRecurrence is returned for unknown repeat rules.
var ErrInvalidRecurrence = errors.New("Unbekannte Wiederholung (erlaubt: täglich, wöchentlich, monatlich, jährlich)")
func validRecurrence(r string) bool {
switch r {
case "", "daily", "weekly", "monthly", "yearly":
return true
}
return false
}
// NextDueDate moves a due date (YYYY-MM-DD, or today when empty) one interval forward. Months and years
// keep the day where possible and fall back to the month's last day (31 Jan -> 28/29 Feb).
func NextDueDate(due, rule string, today time.Time) string {
base, err := time.Parse("2006-01-02", due)
if err != nil {
base = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
}
switch rule {
case "daily":
base = base.AddDate(0, 0, 1)
case "weekly":
base = base.AddDate(0, 0, 7)
case "monthly":
base = addMonthsClamped(base, 1)
case "yearly":
base = addMonthsClamped(base, 12)
}
return base.Format("2006-01-02")
}
func addMonthsClamped(t time.Time, months int) time.Time {
first := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, months, 0)
last := first.AddDate(0, 1, -1).Day()
day := t.Day()
if day > last {
day = last
}
return time.Date(first.Year(), first.Month(), day, 0, 0, 0, 0, time.UTC)
}
// SetTaskRecurrence changes the repeat rule of a task (editors of its list or its owner).
func (s *Store) SetTaskRecurrence(userID, taskID, rule string) error {
if !validRecurrence(rule) {
return ErrInvalidRecurrence
}
t, err := s.GetThoughtByID(userID, taskID)
if err != nil {
return err
}
if err := s.ensureTaskListWritable(userID, t.TaskListID); err != nil {
return err
}
_, err = s.db.Exec(`UPDATE thoughts SET recurrence = ? WHERE id = ?`, rule, taskID)
return err
}
func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) {
t, err := scanThought(s.db.QueryRow(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE id = ?`, id))
if err != nil {
return nil, err
}
t.Assignees = s.getTaskAssignees(t.ID)
return t, nil
}
// spawnNextOccurrence creates the follow-up of a repeating task that was just completed: same title,
// tags, priority, notes, list, assignees and rule, subtasks unchecked, due date one interval later,
// in the list's first open column. Each task spawns at most once, so re-opening and completing it
// again creates no duplicate. The new task goes to OnTaskCreated (live update for all clients).
func (s *Store) spawnNextOccurrence(taskID string) {
t, err := s.getThoughtRaw(taskID)
if err != nil || t.Recurrence == "" || !t.IsCompleted {
return
}
res, err := s.db.Exec(`UPDATE thoughts SET recurrence_spawned = 1 WHERE id = ? AND recurrence_spawned = 0`, taskID)
if err != nil {
return
}
if n, _ := res.RowsAffected(); n == 0 {
return // already spawned its successor
}
column := "todo"
if t.TaskListID != "" {
if tl, err := s.GetTaskListByID(t.UserID, t.TaskListID); err == nil {
for _, c := range tl.Columns {
if !c.IsDone {
column = c.ID
break
}
}
}
}
due := NextDueDate(t.DueDate, t.Recurrence, time.Now())
next, err := s.CreateThoughtWithDetails(t.UserID, t.Content, t.Priority, t.Source, due, t.TaskListID, column, t.Notes)
if err != nil {
return
}
if _, err := s.UpdateThoughtTriage(t.UserID, next.ID, "keep", "task", "", due, t.TaskListID); err != nil {
return
}
subs := make([]domain.SubTask, len(t.Subtasks))
for i, st := range t.Subtasks {
subs[i] = domain.SubTask{ID: domain.NewID("st"), Title: st.Title, CreatedAt: domain.NowISO()}
}
tags := t.Tags
_, _ = s.UpdateThoughtDetails(t.UserID, next.ID, nil, nil, nil, nil, nil, &column, nil, &subs, &tags)
_, _ = s.db.Exec(`UPDATE thoughts SET recurrence = ?, sort_order = ? WHERE id = ?`, t.Recurrence, t.SortOrder, next.ID)
for _, uid := range t.Assignees {
_, _ = s.db.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, next.ID, uid, domain.NowISO())
}
s.PruneTaskAssignees()
if created, err := s.getThoughtRaw(next.ID); err == nil && s.OnTaskCreated != nil {
s.OnTaskCreated(created)
}
}