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 YYYY-MM-DDTHH:MM, today when empty) one interval forward // and keeps the time. 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 { parsed, hasTime, ok := domain.ParseDue(due, time.UTC) base := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC) if ok { base = time.Date(parsed.Year(), parsed.Month(), parsed.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) } if hasTime { return base.Format("2006-01-02") + parsed.Format("T15:04") } 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) } }