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,97 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func TestNextDueDate(t *testing.T) {
|
||||
today := time.Date(2026, 10, 29, 15, 0, 0, 0, time.UTC)
|
||||
cases := []struct{ due, rule, want string }{
|
||||
{"2026-10-30", "daily", "2026-10-31"},
|
||||
{"2026-10-30", "weekly", "2026-11-06"},
|
||||
{"2026-01-31", "monthly", "2026-02-28"},
|
||||
{"2026-03-15", "monthly", "2026-04-15"},
|
||||
{"2028-02-29", "yearly", "2029-02-28"},
|
||||
{"", "weekly", "2026-11-05"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := NextDueDate(c.due, c.rule, today); got != c.want {
|
||||
t.Errorf("NextDueDate(%q, %s) = %s, want %s", c.due, c.rule, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatingTaskSpawnsItsSuccessorOnce(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "recur.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
openSharing(t, store)
|
||||
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "", "")
|
||||
_, _ = store.AddTaskListShare(tl.ID, "user", ben.ID, "editor", anna.ID)
|
||||
|
||||
var pushed []*domain.Thought
|
||||
store.OnTaskCreated = func(th *domain.Thought) { pushed = append(pushed, th) }
|
||||
|
||||
task, _ := store.CreateThoughtWithDetails(anna.ID, "Müll rausbringen #haushalt", "high", "manual", "2026-11-02", tl.ID, "todo", "Gelbe Tonne")
|
||||
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID)
|
||||
subs := []domain.SubTask{{ID: "s1", Title: "Tüten", IsCompleted: true}}
|
||||
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
|
||||
_, _ = store.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID})
|
||||
if err := store.SetTaskRecurrence(anna.ID, task.ID, "weekly"); err != nil {
|
||||
t.Fatalf("SetTaskRecurrence: %v", err)
|
||||
}
|
||||
if err := store.SetTaskRecurrence(anna.ID, task.ID, "stündlich"); err == nil {
|
||||
t.Errorf("unknown rule must be rejected")
|
||||
}
|
||||
|
||||
done := true
|
||||
if _, err := store.UpdateThoughtDetails(ben.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); err != nil {
|
||||
t.Fatalf("complete: %v", err)
|
||||
}
|
||||
if len(pushed) != 1 {
|
||||
t.Fatalf("expected one successor pushed live, got %d", len(pushed))
|
||||
}
|
||||
next := pushed[0]
|
||||
if next.Content != "Müll rausbringen #haushalt" || next.Priority != "high" || next.Notes != "Gelbe Tonne" ||
|
||||
next.DueDate != "2026-11-09" || next.Recurrence != "weekly" || next.IsCompleted || next.TaskListID != tl.ID ||
|
||||
next.KanbanStatus != "todo" || len(next.Subtasks) != 1 || next.Subtasks[0].IsCompleted ||
|
||||
len(next.Assignees) != 1 || next.Assignees[0] != ben.ID || next.UserID != anna.ID {
|
||||
t.Errorf("successor wrong: %+v", next)
|
||||
}
|
||||
if orig, _ := store.GetThoughtByID(anna.ID, task.ID); !orig.IsCompleted {
|
||||
t.Errorf("the completed task stays completed for the history")
|
||||
}
|
||||
|
||||
// Re-opening and completing again creates no duplicate
|
||||
open := false
|
||||
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, &open, nil, nil, nil, nil, nil, nil, nil)
|
||||
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
|
||||
if len(pushed) != 1 {
|
||||
t.Fatalf("re-completing must not spawn again, got %d successors", len(pushed))
|
||||
}
|
||||
|
||||
// Dragging the successor into "done" on the Kanban board repeats as well
|
||||
status := "done"
|
||||
if err := store.ReorderTasks(tl.ID, []domain.ThoughtReorderItem{{ID: next.ID, SortOrder: 0, KanbanStatus: &status}}); err != nil {
|
||||
t.Fatalf("ReorderTasks: %v", err)
|
||||
}
|
||||
if len(pushed) != 2 || pushed[1].DueDate != "2026-11-16" {
|
||||
t.Fatalf("Kanban completion should spawn the next week, got %+v", pushed)
|
||||
}
|
||||
|
||||
// A task without rule never repeats
|
||||
plain, _ := store.CreateThoughtWithDetails(anna.ID, "Einmalig", "normal", "manual", "", tl.ID, "todo", "")
|
||||
_, _ = store.UpdateThoughtTriage(anna.ID, plain.ID, "keep", "task", "", "", tl.ID)
|
||||
_, _ = store.UpdateThoughtDetails(anna.ID, plain.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
|
||||
if len(pushed) != 2 {
|
||||
t.Errorf("non-repeating task must not spawn")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user