Files
MindSift/internal/storage/recurrence_test.go
T
sandrodachandClaude Opus 5.5 588c679c6a
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s
feat(tasks): repeating tasks (daily, weekly, monthly, yearly)
- 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>
2026-10-29 07:23:09 +01:00

98 lines
4.0 KiB
Go

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")
}
}