Due dates can carry a time (YYYY-MM-DDTHH:MM). Alarms fire at that time,
or 09:00 for all-day tasks, minus the user's lead time, in TZ (default
Europe/Berlin, tzdata embedded). A new due date or reopening a task
re-arms the alarm. Recurrence keeps the time; Markdown export/import
carry it. The UI shows relative labels ("Heute, 14:30"), marks overdue
tasks to the minute and groups by local day instead of the UTC date.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
5.6 KiB
Go
141 lines
5.6 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"},
|
|
{"2026-01-31T07:15", "monthly", "2026-02-28T07:15"},
|
|
{"2026-10-30T23:30", "daily", "2026-10-31T23:30"},
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestAlarmResetsOnNewDueDateOrReopen(t *testing.T) {
|
|
store, err := NewStore(filepath.Join(t.TempDir(), "alarm.db"))
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
defer store.Close()
|
|
u, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
|
task, _ := store.CreateThoughtWithDetails(u.ID, "Zahnarzt", "normal", "manual", "2026-10-30 14:30", "", "todo", "")
|
|
_, _ = store.UpdateThoughtTriage(u.ID, task.ID, "keep", "task", "", "", "")
|
|
sent := func() bool {
|
|
th, _ := store.GetThoughtByID(u.ID, task.ID)
|
|
return th.AlarmSent
|
|
}
|
|
if th, _ := store.GetThoughtByID(u.ID, task.ID); th.DueDate != "2026-10-30T14:30" {
|
|
t.Fatalf("due date not normalized: %q", th.DueDate)
|
|
}
|
|
|
|
_ = store.MarkTaskAlarmSent(task.ID)
|
|
same := "2026-10-30T14:30"
|
|
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &same, nil, nil, nil, nil, nil, nil)
|
|
if !sent() {
|
|
t.Errorf("unchanged due date must not re-arm the alarm")
|
|
}
|
|
later := "2026-10-31T08:00"
|
|
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &later, nil, nil, nil, nil, nil, nil)
|
|
if sent() {
|
|
t.Errorf("new due date must re-arm the alarm")
|
|
}
|
|
|
|
_ = store.MarkTaskAlarmSent(task.ID)
|
|
done, open := true, false
|
|
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
|
|
if !sent() {
|
|
t.Errorf("completing keeps the flag")
|
|
}
|
|
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, &open, nil, nil, nil, nil, nil, nil, nil)
|
|
if sent() {
|
|
t.Errorf("reopening must re-arm the alarm")
|
|
}
|
|
}
|