Repository: go-vikunja/vikunja (2026-04-09T10:08:21Z)

Issue:
When finishing a recurring task on the kanban board the task should move to the to-do column
### Pre-submission checklist

- [x] I have searched for existing open or closed issue reports with the same problem.

### Description


Steps to reproduce:

1. Create a recurring task
2. Go to the kanban board
3. drag and drop the task to Doing
4. drag and drop the task to Done (or click mark completed in the task)
5. refresh the screen
> Notice the task in in the doing column with the new date according to the recurrence schedule

Expected result: The task should not be in the Doing column but in the To-Do column 


### Vikunja Version

v2.2.2

### Browser and version

_No response_

### Can you reproduce the bug on the Vikunja demo site?

Yes

### Screenshots

_No response_


Discussion:
This issue has been fixed in [`88b5347`](), please check with the next unstable build (should be ready for deployment in ~30min, also on [the demo](https://try.vikunja.io)).
Thanks for the quick fix!



Patch:
diff --git a/pkg/models/kanban_task_bucket.go b/pkg/models/kanban_task_bucket.go
index 35c623a0f4..0b2b06eaf2 100644
--- a/pkg/models/kanban_task_bucket.go
+++ b/pkg/models/kanban_task_bucket.go
@@ -145,8 +145,20 @@ func updateTaskBucket(s *xorm.Session, a web.Auth, b *TaskBucket) (err error) {
 				oldTask := *task
 				oldTask.Done = false
 				updateDone(&oldTask, task)
-				updateBucket = false
-				b.BucketID = oldTaskBucket.BucketID
+				// A repeating task doesn't stay in the done bucket; route
+				// it back to the view's default bucket so the user sees
+				// the next iteration waiting in the "To-Do" column.
+				b.BucketID, err = getDefaultBucketID(s, view)
+				if err != nil {
+					return err
+				}
+				// If the task is already in the default bucket, skip the
+				// upsert — MySQL's UPDATE returns 0 affected rows when
+				// the value is unchanged, which would make upsert fall
+				// through to INSERT and hit the unique constraint.
+				if b.BucketID == oldTaskBucket.BucketID {
+					updateBucket = false
+				}
 			}
 		}
 
diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go
index 13385e0e48..c2904f2c20 100644
--- a/pkg/models/tasks.go
+++ b/pkg/models/tasks.go
@@ -1172,7 +1172,7 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e
 	}
 
 	views := []*ProjectView{}
-	if (!t.isRepeating() && t.Done != ot.Done) || t.ProjectID != ot.ProjectID {
+	if t.Done != ot.Done || t.ProjectID != ot.ProjectID {
 		err = s.
 			Where("project_id = ? AND view_kind = ? AND bucket_configuration_mode = ?",
 				t.ProjectID, ProjectViewKindKanban, BucketConfigurationModeManual).
@@ -1233,6 +1233,16 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e
 		}
 	}
 
+	// Repeating tasks don't stay in the done bucket — route them back
+	// to the default bucket so the next iteration shows up in the
+	// "To-Do" column. See #2573.
+	if t.ProjectID == ot.ProjectID && t.isRepeating() && !ot.Done && t.Done {
+		err = t.moveTaskToDefaultBuckets(s, a, views)
+		if err != nil {
+			return
+		}
+	}
+
 	// When a repeating task is marked as done, we update all deadlines and reminders and set it as undone
 	updateDoneAt := updateDone(&ot, t)
 	if updateDoneAt {
@@ -1454,6 +1464,42 @@ func (t *Task) moveTaskToDoneBuckets(s *xorm.Session, a web.Auth, views []*Proje
 	return nil
 }
 
+// moveTaskToDefaultBuckets moves the task to the default bucket of
+// every provided view. It's the counterpart to moveTaskToDoneBuckets
+// and is used when a repeating task is marked done: repeating tasks
+// don't stay in the done bucket, so they should be routed back to
+// the default ("To-Do") bucket so the next iteration is visible there.
+func (t *Task) moveTaskToDefaultBuckets(s *xorm.Session, a web.Auth, views []*ProjectView) error {
+	for _, view := range views {
+		defaultBucketID, err := getDefaultBucketID(s, view)
+		if err != nil {
+			return err
+		}
+
+		tb := &TaskBucket{
+			BucketID:      defaultBucketID,
+			TaskID:        t.ID,
+			ProjectViewID: view.ID,
+			ProjectID:     t.ProjectID,
+		}
+		err = updateTaskBucket(s, a, tb)
+		if err != nil {
+			return err
+		}
+
+		tp := TaskPosition{
+			TaskID:        t.ID,
+			ProjectViewID: view.ID,
+			Position:      calculateDefaultPosition(t.Index, t.Position),
+		}
+		err = updateTaskPosition(s, a, &tp)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func addOneMonthToDate(d time.Time) time.Time {
 	return time.Date(d.Year(), d.Month()+1, d.Day(), d.Hour(), d.Minute(), d.Second(), d.Nanosecond(), config.GetTimeZone())
 }


Test patch:
diff --git a/pkg/models/kanban_task_bucket_test.go b/pkg/models/kanban_task_bucket_test.go
index a4d7913704..6d1eb2f242 100644
--- a/pkg/models/kanban_task_bucket_test.go
+++ b/pkg/models/kanban_task_bucket_test.go
@@ -175,6 +175,56 @@ func TestTaskBucket_Update(t *testing.T) {
 		assert.Equal(t, updatedTask.StartDate.Unix(), tb.Task.StartDate.Unix())
 		assert.Equal(t, updatedTask.EndDate.Unix(), tb.Task.EndDate.Unix())
 	})
+	t.Run("moving a repeating task from a non-default bucket to the done bucket moves it to the default bucket", func(t *testing.T) {
+		db.LoadAndAssertFixtures(t)
+		s := db.NewSession()
+		defer s.Close()
+
+		// Task 28 is a repeating task. Fixtures place it in bucket 1 (the
+		// default bucket on view 4). Pre-position it in bucket 2 ("Doing")
+		// using a raw update so we can bypass the bucket-2 limit check —
+		// the limit check would otherwise block this setup step since
+		// bucket 2 is already at its limit of 3 tasks.
+		_, err := s.Where("task_id = ? AND project_view_id = ?", 28, 4).
+			Cols("bucket_id").
+			Update(&TaskBucket{BucketID: 2})
+		require.NoError(t, err)
+
+		tb := &TaskBucket{
+			TaskID:        28,
+			BucketID:      3, // Bucket 3 is the done bucket on view 4
+			ProjectViewID: 4,
+			ProjectID:     1,
+		}
+		err = tb.Update(s, u)
+		require.NoError(t, err)
+		err = s.Commit()
+		require.NoError(t, err)
+
+		// Repeating task should have been re-opened by updateDone...
+		assert.False(t, tb.Task.Done)
+
+		// ...and routed to the DEFAULT bucket (1), not left in the source
+		// bucket (2) and not placed in the done bucket (3).
+		assert.Equal(t, int64(1), tb.BucketID)
+
+		db.AssertExists(t, "tasks", map[string]interface{}{
+			"id":   28,
+			"done": false,
+		}, false)
+		db.AssertExists(t, "task_buckets", map[string]interface{}{
+			"task_id":   28,
+			"bucket_id": 1,
+		}, false)
+		db.AssertMissing(t, "task_buckets", map[string]interface{}{
+			"task_id":   28,
+			"bucket_id": 2,
+		})
+		db.AssertMissing(t, "task_buckets", map[string]interface{}{
+			"task_id":   28,
+			"bucket_id": 3,
+		})
+	})
 
 	t.Run("keep done timestamp when moving task between projects", func(t *testing.T) {
 		db.LoadAndAssertFixtures(t)
diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go
index 445cfd8c32..fdae4e4fd2 100644
--- a/pkg/models/tasks_test.go
+++ b/pkg/models/tasks_test.go
@@ -392,6 +392,52 @@ func TestTask_Update(t *testing.T) {
 		assert.False(t, updatedTask.Done)
 		assert.False(t, updatedTask.DoneAt.IsZero(), "done_at should be persisted in database for repeating tasks")
 	})
+	t.Run("repeating tasks marked done from a non-default bucket are moved to the default bucket", func(t *testing.T) {
+		db.LoadAndAssertFixtures(t)
+		s := db.NewSession()
+		defer s.Close()
+
+		// Pre-position task 28 in bucket 2 (non-default, non-done) via a
+		// raw update to bypass the bucket-limit check.
+		_, err := s.Where("task_id = ? AND project_view_id = ?", 28, 4).
+			Cols("bucket_id").
+			Update(&TaskBucket{BucketID: 2})
+		require.NoError(t, err)
+
+		// Mark the repeating task as done via Task.Update (same code path
+		// the frontend hits when the user clicks "Done" in the task
+		// detail pane).
+		task := &Task{
+			ID:          28,
+			Done:        true,
+			RepeatAfter: 3600,
+		}
+		err = task.Update(s, u)
+		require.NoError(t, err)
+		err = s.Commit()
+		require.NoError(t, err)
+
+		// updateDone should have re-opened the task for the next iteration.
+		assert.False(t, task.Done)
+
+		// And the task should now be sitting in the default bucket (1),
+		// not left in bucket 2 or moved to the done bucket (3).
+		db.AssertExists(t, "task_buckets", map[string]interface{}{
+			"task_id":         28,
+			"project_view_id": 4,
+			"bucket_id":       1,
+		}, false)
+		db.AssertMissing(t, "task_buckets", map[string]interface{}{
+			"task_id":         28,
+			"project_view_id": 4,
+			"bucket_id":       2,
+		})
+		db.AssertMissing(t, "task_buckets", map[string]interface{}{
+			"task_id":         28,
+			"project_view_id": 4,
+			"bucket_id":       3,
+		})
+	})
 	t.Run("moving a task between projects should give it a correct index", func(t *testing.T) {
 		db.LoadAndAssertFixtures(t)
 		s := db.NewSession()