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>
130 lines
4.3 KiB
Go
130 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|