feat(tasks): repeating tasks (daily, weekly, monthly, yearly)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s
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:
1 parent
0e9fd1b3ad
commit
588c679c6a
16 files changed
+376
-19
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user