Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s

Repeating tasks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 07:23:11 +01:00
commit 79643d34f2
16 files changed
+376 -19

No files matched your search

+1 -1
View File
@@ -71,7 +71,7 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|---|---|---|
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
| GET | `/triage/deck` | Posteingang (max. 100) |
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
+9
View File
@@ -27,6 +27,15 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
- Bekannte Lücke: [FLAW-03](../flaws.md) (Umsortieren per ID).
## Wiederkehrende Aufgaben
- Im Detail-Fenster unter der Fälligkeit: *Keine Wiederholung*, täglich, wöchentlich, monatlich oder jährlich (`thoughts.recurrence`, `PATCH /thoughts/{id}` mit `recurrence`). Zeilen und Kanban-Karten zeigen dann ein 🔁-Symbol.
- **Abhaken erzeugt die nächste Aufgabe** ([recurrence.go](../../internal/storage/recurrence.go)): egal ob im Detail, in der Liste, per Ziehen in „Erledigt“ im Kanban oder über die Checkbox in einer Notiz. Die erledigte Aufgabe bleibt erledigt (Historie); die neue übernimmt Titel, Tags, Priorität, Notizen, Liste, Zuweisungen und die Regel, Unteraufgaben kommen unerledigt mit, sie steht in der ersten offenen Spalte.
- **Fälligkeit:** das bisherige Datum plus ein Intervall (ohne Datum ab heute). Monate und Jahre behalten den Tag oder rutschen auf das Monatsende (31.1. → 28./29.2.).
- Jede Aufgabe erzeugt höchstens eine Nachfolgerin (`recurrence_spawned`): Wieder öffnen und erneut abhaken legt keine zweite an.
- Die neue Aufgabe geht per `TASK_CREATED` an alle Beteiligten, sie erscheint ohne Neuladen.
- Export/Import: im Markdown-Archiv als `🔁 every week` (Format des Obsidian-Tasks-Plugins), im JSON-Backup als Feld; beides wird beim Import wiederhergestellt.
## Alarme
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
+3
View File
@@ -558,6 +558,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li>
<strong className="text-main">Zuweisen:</strong> In geteilten Aufgabenlisten weist du eine Aufgabe einer oder mehreren Personen mit Zugriff zu (Namen tippen, Enter). Die Zugewiesenen erscheinen als kleine Profilbilder an der Aufgabe; wer den Zugriff auf die Liste verliert, wird automatisch entfernt.
</li>
<li>
<strong className="text-main">Wiederkehrende Aufgaben:</strong> Im Detail-Fenster eine Wiederholung wählen (täglich bis jährlich). Beim Abhaken entsteht automatisch die nächste Aufgabe mit fortgeschriebenem Datum; erkennbar am 🔁-Symbol.
</li>
<li>
<strong className="text-main">Automatische Alarme:</strong> Ist ein Fälligkeitsdatum erreicht, kann MindSift dich automatisch per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook benachrichtigen. Konfigurierbar in deinen Benutzereinstellungen.
</li>
@@ -17,8 +17,9 @@ import {
Webhook,
Lock,
UserPlus,
Repeat,
} from 'lucide-react';
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User } from '../../lib/types';
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api';
@@ -34,6 +35,7 @@ interface TaskDetailModalProps {
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
onUpdateRecurrence?: (task: Thought, recurrence: Recurrence) => Promise<void>;
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
@@ -58,6 +60,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onUpdateStatus,
onUpdatePriority,
onUpdateDueDate,
onUpdateRecurrence,
onUpdateTaskList,
onUpdateNotes,
onUpdateTags,
@@ -509,6 +512,32 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
+1 Woche
</button>
</div>
{onUpdateRecurrence && (
<div className="pt-1">
<label className="flex items-center gap-1 text-[11px] text-muted mb-1">
<Repeat className="w-3 h-3 text-accent" />
<span>Wiederholung</span>
</label>
<select
value={task.recurrence || ''}
disabled={readOnly}
onChange={(e) => onUpdateRecurrence(task, e.target.value as Recurrence)}
className="w-full h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
>
<option value="">Keine Wiederholung</option>
<option value="daily">🔁 Täglich</option>
<option value="weekly">🔁 Wöchentlich</option>
<option value="monthly">🔁 Monatlich</option>
<option value="yearly">🔁 Jährlich</option>
</select>
{task.recurrence && (
<p className="text-[10px] text-faint mt-1">
Beim Erledigen entsteht automatisch die nächste Aufgabe, fällig{' '}
{task.dueDate ? 'ein Intervall nach diesem Datum' : 'ein Intervall nach dem Erledigen'}.
</p>
)}
</div>
)}
</div>
{/* Task List assignment */}
+22 -1
View File
@@ -25,8 +25,9 @@ import {
Folder,
Filter,
Webhook,
Repeat,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
@@ -504,6 +505,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
await api.updateThought(task.id, { notes: newNotes });
};
const handleUpdateTaskRecurrence = async (task: Thought, recurrence: Recurrence) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence } : t)));
try {
await api.updateThought(task.id, { recurrence });
} catch {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence: task.recurrence } : t)));
}
};
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
triggerHaptic(10);
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
@@ -1306,6 +1316,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>{task.dueDate}</span>
</span>
)}
{task.recurrence && (
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
<Repeat className="w-3 h-3" />
</span>
)}
{/* Hashtag badges */}
{task.tags?.map((tag) => (
@@ -2131,6 +2146,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onUpdateStatus={handleSetKanbanStatus}
onUpdatePriority={handleSetPriority}
onUpdateDueDate={handleUpdateTaskDueDate}
onUpdateRecurrence={handleUpdateTaskRecurrence}
onUpdateTaskList={handleUpdateTaskTaskList}
onUpdateNotes={handleUpdateTaskNotes}
onUpdateTags={handleUpdateTaskTags}
@@ -2324,6 +2340,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>{task.dueDate}</span>
</span>
)}
{task.recurrence && (
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
<Repeat className="w-2.5 h-2.5" />
</span>
)}
{task.tags?.map((tag) => (
<button
key={tag}
+1
View File
@@ -390,6 +390,7 @@ export const api = {
subtasks?: SubTask[];
tags?: string[];
assignees?: string[];
recurrence?: string;
}
): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
+11
View File
@@ -57,6 +57,16 @@ export interface SubTask {
createdAt?: string;
}
// Repeat rule of a task: completing it creates the next one (due date one interval later)
export type Recurrence = '' | 'daily' | 'weekly' | 'monthly' | 'yearly';
export const RECURRENCE_LABELS: Record<Exclude<Recurrence, ''>, string> = {
daily: 'täglich',
weekly: 'wöchentlich',
monthly: 'monatlich',
yearly: 'jährlich',
};
export interface Thought {
id: string;
userId: string;
@@ -76,6 +86,7 @@ export interface Thought {
notes?: string;
subtasks?: SubTask[];
assignees?: string[]; // user IDs, only people with access to the task list
recurrence?: Recurrence;
alarmSent?: boolean;
sortOrder?: number;
createdAt: string;
+12
View File
@@ -43,6 +43,10 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
notifier: collab.NewNotifier(),
}
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
// Next occurrences of repeating tasks appear live on every connected client
store.OnTaskCreated = func(th *domain.Thought) {
srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th)
}
go srv.startAlarmTicker()
go srv.startCompaction()
return srv
@@ -957,6 +961,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
Assignees *[]string `json:"assignees"`
Recurrence *string `json:"recurrence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -964,6 +969,13 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
}
before, _ := s.store.GetThoughtByID(userID, thoughtID)
// The rule is set first, so completing a task in the same request already repeats it
if req.Recurrence != nil {
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
}
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
+2 -1
View File
@@ -183,7 +183,8 @@ type Thought struct {
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
Notes string `json:"notes,omitempty"`
Subtasks []SubTask `json:"subtasks"`
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
Recurrence string `json:"recurrence,omitempty"` // "" | daily | weekly | monthly | yearly
AlarmSent bool `json:"alarmSent,omitempty"`
SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
+1 -1
View File
@@ -482,7 +482,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string {
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
return 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
`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 = ?`, thoughtID))
}
+6
View File
@@ -118,6 +118,9 @@ func (s *Store) writeVaultLists(zw *zip.Writer, userID string, names *vaultNames
return nil
}
// Obsidian Tasks writes repeat rules as "🔁 every week"
var recurrenceEvery = map[string]string{"daily": "day", "weekly": "week", "monthly": "month", "yearly": "year"}
// taskMarkdownLine writes a task in the format of the Obsidian Tasks plugin (📅 due date, ⏫/🔺 priority)
// with its subtasks and notes indented below.
func taskMarkdownLine(t domain.Thought) string {
@@ -134,6 +137,9 @@ func taskMarkdownLine(t domain.Thought) string {
case "low":
line += " 🔽"
}
if every, ok := recurrenceEvery[t.Recurrence]; ok {
line += " 🔁 every " + every
}
if t.DueDate != "" {
line += " 📅 " + t.DueDate
}
+13 -4
View File
@@ -60,9 +60,9 @@ type importDoc struct {
}
type importTask struct {
content, priority, due, notes string
done bool
subtasks []domain.SubTask
content, priority, due, notes, recurrence string
done bool
subtasks []domain.SubTask
}
type importTaskList struct {
@@ -393,7 +393,7 @@ func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
plan.TaskLists = append(plan.TaskLists, importTaskList{title: tl.Title, icon: tl.Icon})
}
for _, t := range exp.Thoughts {
task := importTask{content: t.Content, priority: t.Priority, due: t.DueDate, notes: t.Notes, done: t.IsCompleted, subtasks: t.Subtasks}
task := importTask{content: t.Content, priority: t.Priority, due: t.DueDate, notes: t.Notes, done: t.IsCompleted, subtasks: t.Subtasks, recurrence: t.Recurrence}
switch {
case t.Status == domain.StatusInbox:
plan.Inbox = append(plan.Inbox, task)
@@ -506,6 +506,10 @@ func (s *Store) importTaskInto(userID, listID string, t importTask, asTask bool)
if _, err := s.UpdateThoughtTriage(userID, th.ID, "keep", "task", "", t.due, listID); err != nil {
return fmt.Errorf("Aufgabe %q: %w", content, err)
}
if validRecurrence(t.recurrence) && t.recurrence != "" {
// Imported as already handled: completing a restored task must not create a second successor
_, _ = s.db.Exec(`UPDATE thoughts SET recurrence = ?, recurrence_spawned = ? WHERE id = ?`, t.recurrence, boolInt(t.done), th.ID)
}
if t.done || len(t.subtasks) > 0 {
var done *bool
if t.done {
@@ -754,6 +758,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
}
var (
recurRe = regexp.MustCompile(`(?i)[ \t]*🔁[ \t]*every[ \t]+(day|week|month|year)\b`)
dueRe = regexp.MustCompile(`[ \t]*📅[ \t]*(\d{4}-\d{2}-\d{2})`)
priorityRe = regexp.MustCompile(`[ \t]*(🔺|⏫|🔼|🔽|⏬)`)
)
@@ -790,6 +795,10 @@ func parseTaskFile(body string) []importTask {
if c := checkboxRe.FindStringSubmatch(m[2]); c != nil {
t.done, t.content = c[1] != " ", c[2]
}
if r := recurRe.FindStringSubmatch(t.content); r != nil {
t.recurrence = map[string]string{"day": "daily", "week": "weekly", "month": "monthly", "year": "yearly"}[strings.ToLower(r[1])]
t.content = recurRe.ReplaceAllString(t.content, "")
}
if d := dueRe.FindStringSubmatch(t.content); d != nil {
t.due = d[1]
t.content = dueRe.ReplaceAllString(t.content, "")
+2 -2
View File
@@ -41,6 +41,7 @@ func seedAnna(t *testing.T, store *Store, anna *domain.User) {
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID)
subs := []domain.SubTask{{ID: "s1", Title: "Leiter holen", IsCompleted: true}}
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
_ = store.SetTaskRecurrence(anna.ID, task.ID, "monthly")
list, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping")
item, _ := store.CreateListItem(list.ID, "Milch", "Kühlregal", "2L", 0, anna.ID)
_, _ = store.ToggleListItem(item.ID, true, anna.ID, "Anna")
@@ -66,7 +67,7 @@ func checkRestored(t *testing.T, store *Store, ben *domain.User) {
t.Fatalf("task list not restored: %+v / %+v", lists, tasks)
}
tk := tasks[0]
if tk.Content != "Fenster putzen" || tk.Priority != "urgent" || tk.DueDate != "2026-11-02" || tk.Notes != "Leiter im Keller" ||
if tk.Content != "Fenster putzen" || tk.Priority != "urgent" || tk.DueDate != "2026-11-02" || tk.Notes != "Leiter im Keller" || tk.Recurrence != "monthly" ||
len(tk.Subtasks) != 1 || !tk.Subtasks[0].IsCompleted || tk.TaskListID != lists[0].ID {
t.Errorf("task not restored: %+v", tk)
}
@@ -218,4 +219,3 @@ func TestImportBackupZip(t *testing.T) {
}
checkRestored(t, store, ben)
}
+129
View File
@@ -0,0 +1,129 @@
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)
}
}
+97
View File
@@ -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")
}
}
+37 -8
View File
@@ -22,6 +22,10 @@ import (
type Store struct {
db *sql.DB
initMu sync.Mutex
// OnTaskCreated is told about tasks the store creates by itself (next occurrence of a repeating
// task), so the server can push them to connected clients.
OnTaskCreated func(*domain.Thought)
}
func NewStore(dbPath string) (*Store, error) {
@@ -454,6 +458,10 @@ func (s *Store) migrate() error {
// Who added an item to a (shopping) list
"ALTER TABLE list_items ADD COLUMN created_by TEXT NOT NULL DEFAULT '';",
// Repeating tasks: daily | weekly | monthly | yearly
"ALTER TABLE thoughts ADD COLUMN recurrence TEXT NOT NULL DEFAULT '';",
"ALTER TABLE thoughts ADD COLUMN recurrence_spawned INTEGER NOT NULL DEFAULT 0;",
}
for _, q := range columnMigrations {
_, _ = s.db.Exec(q)
@@ -938,7 +946,7 @@ func scanThought(rows interface {
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder,
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
)
if err != nil {
return nil, err
@@ -981,7 +989,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
row := 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
`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 = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
thoughtID, userID, userID, userID, userID,
)
@@ -1002,14 +1010,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
if statusFilter != "" && statusFilter != "all" {
rows, err = s.db.Query(
`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
`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 user_id = ? AND status = ?
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
userID, statusFilter, limit,
)
} else {
rows, err = s.db.Query(
`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
`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 user_id = ?
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
userID, limit,
@@ -1035,7 +1043,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
var err error
if taskListID != "" {
rows, err = s.db.Query(
`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
`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 (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND task_list_id = ?
@@ -1045,7 +1053,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
)
} else {
rows, err = s.db.Query(
`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
`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 (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
@@ -1076,11 +1084,17 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
}
defer tx.Rollback()
var completed []string // tasks that move into "done" now (repeating ones spawn their successor)
for _, it := range items {
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
isCompleted := 0
if *it.KanbanStatus == "done" {
isCompleted = 1
var was int
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
if was == 0 {
completed = append(completed, it.ID)
}
}
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`,
@@ -1097,7 +1111,13 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
}
}
return tx.Commit()
if err := tx.Commit(); err != nil {
return err
}
for _, id := range completed {
s.spawnNextOccurrence(id)
}
return nil
}
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
@@ -1231,6 +1251,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
if err != nil {
return nil, err
}
wasCompleted := thought.IsCompleted
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
@@ -1303,6 +1324,9 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
s.PruneTaskAssignees()
thought.Assignees = s.getTaskAssignees(thoughtID)
}
if !wasCompleted && thought.IsCompleted {
s.spawnNextOccurrence(thoughtID)
}
return thought, nil
}
@@ -3942,7 +3966,12 @@ func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
doneInt = 1
kanban = "done"
}
_, _ = s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ?`, doneInt, kanban, taskID, userID)
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, taskID, userID, doneInt)
if checked && res != nil {
if n, _ := res.RowsAffected(); n > 0 {
s.spawnNextOccurrence(taskID)
}
}
}
func (s *Store) DeleteListItem(itemID string) (string, error) {