feat(tasks): completed tasks are deleted after a delay (default 30 days), "Erledigte leeren"
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m34s

thoughts.completed_at is set by triggers on every way a task is completed (migration 14; tasks
completed earlier count from the update). Users choose the delay (7/30/90/365 days or never,
users.done_retention_days); for tasks in a list the owner's delay counts. An hourly job deletes
expired completed tasks (not in the demo) and tells their viewers. POST /tasks/clear-done clears
completed tasks the user may edit. The done tab shows the setting and the button. v0.35.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 12:18:54 +01:00
1 parent 66b28e1998
commit f91926c208
16 files changed
+433 -6

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
// startDoneCleanupTicker deletes completed tasks whose delay ran out (storage/done_cleanup.go), once
// shortly after the start and then every hour.
func (s *Server) startDoneCleanupTicker() {
time.Sleep(2 * time.Minute)
s.runDoneCleanup(time.Now())
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for now := range ticker.C {
s.runDoneCleanup(now)
}
}
func (s *Server) runDoneCleanup(now time.Time) {
deleted, err := s.store.PurgeExpiredDoneTasks(now)
if err != nil {
slog.Error("Aufräumen erledigter Aufgaben fehlgeschlagen", "error", err)
return
}
if len(deleted) > 0 {
slog.Info("Erledigte Aufgaben aufgeräumt", "count", len(deleted))
}
s.broadcastDeletedTasks(deleted)
}
// broadcastDeletedTasks tells everyone who saw the tasks; there are no watcher notifications for this.
func (s *Server) broadcastDeletedTasks(deleted []storage.DeletedTask) {
for _, d := range deleted {
audience := append(s.store.GetTaskListUserIDs(d.TaskListID), d.UserID)
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": d.ID})
}
}
// handleClearDoneTasks: "Erledigte leeren" for one task list (taskListId) or all the user can edit.
func (s *Server) handleClearDoneTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
TaskListID string `json:"taskListId"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
deleted, err := s.store.ClearDoneTasks(userID, req.TaskListID)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
s.broadcastDeletedTasks(deleted)
writeJSON(w, http.StatusOK, map[string]int{"deleted": len(deleted)})
}
+10
View File
@@ -403,6 +403,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
stats, _ := s.store.GetDashboardStats(userID)
ns := s.store.NotificationSettingsOf(userID)
user.NotificationSettings = &ns
user.DoneRetentionDays = s.store.DoneRetentionDays(userID)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user": user,
"stats": stats,
@@ -512,6 +513,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
CurrentPassword string `json:"currentPassword"`
// Which notifications go where (notify.go)
NotificationSettings *domain.NotificationSettings `json:"notificationSettings"`
// Days after which completed tasks are deleted (done_cleanup.go)
DoneRetentionDays *int `json:"doneRetentionDays"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -598,8 +601,15 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
return
}
}
if req.DoneRetentionDays != nil {
if err := s.store.SetDoneRetentionDays(userID, *req.DoneRetentionDays); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
ns := s.store.NotificationSettingsOf(userID)
u.NotificationSettings = &ns
u.DoneRetentionDays = s.store.DoneRetentionDays(userID)
writeJSON(w, http.StatusOK, u)
}
+2
View File
@@ -71,6 +71,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
go srv.startNotificationJobs()
go srv.startBackupScheduler()
go srv.startDoneCleanupTicker()
}
return srv
}
@@ -190,6 +191,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/tasks", s.handleGetTasks)
r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask)
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
r.Post("/api/v1/tasks/clear-done", s.handleClearDoneTasks)
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
// Task Lists & Shares
+2
View File
@@ -141,6 +141,8 @@ type User struct {
EmailUnverified bool `json:"emailUnverified,omitempty"`
// Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings)
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
// DoneRetentionDays: completed tasks are deleted this many days after ticking them off (0 = never)
DoneRetentionDays int `json:"doneRetentionDays"`
}
// NotificationSettings: which events reach a user over which channel, the daily summary and a pause.
+148
View File
@@ -0,0 +1,148 @@
package storage
import (
"errors"
"strings"
"time"
)
// Completed tasks are deleted a while after they were ticked off, so the "Erledigt" tab does not fill
// up for good. Every user chooses the delay (done_retention_days, 0 = never); for tasks in a task list
// the owner's choice counts, for personal tasks the user's own. thoughts.completed_at is kept by two
// triggers, so every way of completing a task (app, Kanban, note checkbox, API) sets it.
// DoneRetentionChoices are the delays offered in the app, in days; 0 keeps completed tasks.
var DoneRetentionChoices = []int{0, 7, 30, 90, 365}
const DefaultDoneRetentionDays = 30
var ErrInvalidRetention = errors.New("Ungültige Frist für erledigte Aufgaben (erlaubt: 0, 7, 30, 90, 365 Tage)")
func (s *Store) migrateDoneCleanup() error {
if err := s.addColumnIfMissing("users", "done_retention_days", "INTEGER NOT NULL DEFAULT 30"); err != nil {
return err
}
if err := s.addColumnIfMissing("thoughts", "completed_at", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
return s.execInTx(
// Tasks completed before this update count from now on, so nothing disappears at once
stmt(`UPDATE thoughts SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE is_completed = 1 AND completed_at = ''`),
stmt(`CREATE TRIGGER IF NOT EXISTS trg_thoughts_completed AFTER UPDATE OF is_completed ON thoughts
WHEN NEW.is_completed != OLD.is_completed BEGIN
UPDATE thoughts SET completed_at = CASE WHEN NEW.is_completed = 1 THEN strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ELSE '' END
WHERE id = NEW.id;
END`),
stmt(`CREATE TRIGGER IF NOT EXISTS trg_thoughts_completed_insert AFTER INSERT ON thoughts
WHEN NEW.is_completed = 1 AND NEW.completed_at = '' BEGIN
UPDATE thoughts SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = NEW.id;
END`),
)
}
// DoneRetentionDays: after how many days the user's completed tasks are deleted (0 = never).
func (s *Store) DoneRetentionDays(userID string) int {
days := DefaultDoneRetentionDays
_ = s.db.QueryRow(`SELECT done_retention_days FROM users WHERE id = ?`, userID).Scan(&days)
return days
}
// SetDoneRetentionDays stores one of DoneRetentionChoices; other values are refused.
func (s *Store) SetDoneRetentionDays(userID string, days int) error {
ok := false
for _, d := range DoneRetentionChoices {
ok = ok || d == days
}
if !ok {
return ErrInvalidRetention
}
_, err := s.db.Exec(`UPDATE users SET done_retention_days = ? WHERE id = ?`, days, userID)
return err
}
// DeletedTask: what callers need to tell everyone who saw a deleted task.
type DeletedTask struct {
ID, UserID, TaskListID string
}
// Completed tasks: a kept task, a converted task or a task linked to a note checkbox
const completedTaskSQL = `t.is_completed = 1 AND (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))`
// PurgeExpiredDoneTasks deletes completed tasks whose delay has run out at now: the task list owner's
// delay, or the user's own for personal tasks. A note checkbox linked to such a task stays as a plain
// checkbox (the link lives on the task).
func (s *Store) PurgeExpiredDoneTasks(now time.Time) ([]DeletedTask, error) {
rows, err := s.db.Query(`
SELECT t.id, t.user_id, t.task_list_id
FROM thoughts t
LEFT JOIN task_lists tl ON tl.id = t.task_list_id AND t.task_list_id != ''
JOIN users u ON u.id = COALESCE(tl.user_id, t.user_id)
WHERE `+completedTaskSQL+`
AND t.completed_at != '' AND u.done_retention_days > 0
AND t.completed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || u.done_retention_days || ' days')`,
now.UTC().Format("2006-01-02 15:04:05"))
if err != nil {
return nil, err
}
return s.deleteTasks(rows)
}
// ClearDoneTasks deletes the completed tasks of one task list ("" = all lists the user can see) that
// the user may delete: own personal tasks and tasks in lists they own or edit.
func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error) {
if taskListID != "" {
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
rows, err := s.db.Query(`SELECT t.id, t.user_id, t.task_list_id FROM thoughts t
WHERE `+completedTaskSQL+` AND t.task_list_id = ?`, taskListID)
if err != nil {
return nil, err
}
return s.deleteTasks(rows)
}
lists, err := s.GetTaskLists(userID)
if err != nil {
return nil, err
}
writable := []any{userID}
marks := []string{}
for _, l := range lists {
if l.Permission != "viewer" {
writable = append(writable, l.ID)
marks = append(marks, "?")
}
}
inLists := ""
if len(marks) > 0 {
inLists = ` OR t.task_list_id IN (` + strings.Join(marks, ",") + `)`
}
rows, err := s.db.Query(`SELECT t.id, t.user_id, t.task_list_id FROM thoughts t
WHERE `+completedTaskSQL+` AND ((t.user_id = ? AND t.task_list_id = '')`+inLists+`)`, writable...)
if err != nil {
return nil, err
}
return s.deleteTasks(rows)
}
// deleteTasks deletes the tasks the query rows name and returns them.
func (s *Store) deleteTasks(rows interface {
Next() bool
Scan(dest ...any) error
Close() error
}) ([]DeletedTask, error) {
var out []DeletedTask
for rows.Next() {
var d DeletedTask
if rows.Scan(&d.ID, &d.UserID, &d.TaskListID) == nil {
out = append(out, d)
}
}
_ = rows.Close()
for _, d := range out {
if _, err := s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, d.ID); err != nil {
return nil, err
}
}
return out, nil
}
+128
View File
@@ -0,0 +1,128 @@
package storage
import (
"path/filepath"
"testing"
"time"
)
func doneTask(t *testing.T, s *Store, userID, listID, content, completedAt string) string {
t.Helper()
th, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", listID, "todo", "")
if err != nil {
t.Fatal(err)
}
if _, err := s.UpdateThoughtTriage(userID, th.ID, "keep", "task", "", "", listID); err != nil {
t.Fatal(err)
}
done := true
if _, err := s.UpdateThoughtDetails(userID, th.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
if completedAt != "" {
_, _ = s.db.Exec(`UPDATE thoughts SET completed_at = ? WHERE id = ?`, completedAt, th.ID)
}
return th.ID
}
func exists(s *Store, id string) bool {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE id = ?`, id).Scan(&n)
return n == 1
}
// Completing a task stamps completed_at, reopening clears it (trigger, whichever path changes the task).
func TestCompletedAtFollowsCompletion(t *testing.T) {
s, err := NewStore(filepath.Join(t.TempDir(), "done.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
id := doneTask(t, s, anna.ID, "", "Filter wechseln", "")
var at string
_ = s.db.QueryRow(`SELECT completed_at FROM thoughts WHERE id = ?`, id).Scan(&at)
if _, err := time.Parse("2006-01-02T15:04:05Z", at); err != nil {
t.Fatalf("completed_at not set on completion: %q", at)
}
open := false
_, _ = s.UpdateThoughtDetails(anna.ID, id, nil, &open, nil, nil, nil, nil, nil, nil, nil)
_ = s.db.QueryRow(`SELECT completed_at FROM thoughts WHERE id = ?`, id).Scan(&at)
if at != "" {
t.Fatalf("reopening should clear completed_at: %q", at)
}
}
// The delay of the list owner (or the user for personal tasks) decides; 0 keeps everything.
func TestPurgeExpiredDoneTasks(t *testing.T) {
s, err := NewStore(filepath.Join(t.TempDir(), "purge.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
openSharing(t, s)
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
if s.DoneRetentionDays(anna.ID) != 30 {
t.Fatalf("default delay should be 30 days")
}
_ = s.SetDoneRetentionDays(ben.ID, 0) // Ben keeps everything
if err := s.SetDoneRetentionDays(ben.ID, 12); err == nil {
t.Fatal("only the offered delays are allowed")
}
shared, _ := s.CreateTaskList(anna.ID, "", "", "Haushalt", "", "")
if _, err := s.AddTaskListShare(shared.ID, "user", ben.ID, "editor", anna.ID); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 12, 15, 0, 30, 0, 0, time.UTC)
oldAnna := doneTask(t, s, anna.ID, "", "alt", "2026-11-10T08:00:00Z") // 35 days → gone
freshAnna := doneTask(t, s, anna.ID, "", "frisch", "2026-11-20T08:00:00Z") // 25 days → stays
oldBen := doneTask(t, s, ben.ID, "", "Ben alt", "2026-01-01T08:00:00Z") // Ben: never
benInAnnasList := doneTask(t, s, ben.ID, shared.ID, "in Annas Liste", "2026-11-01T08:00:00Z")
openTask, _ := s.CreateThoughtWithDetails(anna.ID, "offen", "normal", "manual", "", "", "todo", "")
deleted, err := s.PurgeExpiredDoneTasks(now)
if err != nil {
t.Fatal(err)
}
if exists(s, oldAnna) || !exists(s, freshAnna) || !exists(s, oldBen) || exists(s, benInAnnasList) || !exists(s, openTask.ID) {
t.Fatalf("wrong tasks purged: %+v", deleted)
}
if len(deleted) != 2 {
t.Fatalf("expected 2 deleted, got %d", len(deleted))
}
// Edge: exactly at the limit nothing happens yet
edge := doneTask(t, s, anna.ID, "", "Grenze", "2026-11-15T00:30:00Z")
_, _ = s.PurgeExpiredDoneTasks(now)
if !exists(s, edge) {
t.Fatal("a task completed exactly 30 days ago stays until the limit has passed")
}
}
// "Erledigte leeren" only deletes what the user may delete.
func TestClearDoneTasks(t *testing.T) {
s, err := NewStore(filepath.Join(t.TempDir(), "clear.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
openSharing(t, s)
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
readOnly, _ := s.CreateTaskList(anna.ID, "", "", "Nur lesen", "", "")
_, _ = s.AddTaskListShare(readOnly.ID, "user", ben.ID, "viewer", anna.ID)
annaDone := doneTask(t, s, anna.ID, readOnly.ID, "Annas erledigt", "")
benDone := doneTask(t, s, ben.ID, "", "Bens erledigt", "")
if _, err := s.ClearDoneTasks(ben.ID, readOnly.ID); err == nil {
t.Fatal("a viewer must not clear a list")
}
deleted, err := s.ClearDoneTasks(ben.ID, "")
if err != nil || len(deleted) != 1 || exists(s, benDone) || !exists(s, annaDone) {
t.Fatalf("Ben should only clear his own: %+v %v", deleted, err)
}
if deleted, _ := s.ClearDoneTasks(anna.ID, readOnly.ID); len(deleted) != 1 || exists(s, annaDone) {
t.Fatalf("owner clears her list: %+v", deleted)
}
}
+1
View File
@@ -34,6 +34,7 @@ var migrations = []migration{
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
{14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup},
}
func (s *Store) migrate() error {