From 260a0398e5f2305e3d8a4dc9b6cf125c6950ccb2 Mon Sep 17 00:00:00 2001 From: nithinvarma411 Date: Thu, 28 May 2026 20:49:14 +0530 Subject: [PATCH 1/2] fix: recurring tasks return to original bucket when no default is set --- pkg/models/kanban_task_bucket.go | 14 +++++-- pkg/models/kanban_task_bucket_test.go | 55 +++++++++++++++++++++++++++ pkg/models/tasks.go | 28 ++++++++++++-- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/pkg/models/kanban_task_bucket.go b/pkg/models/kanban_task_bucket.go index 0b2b06eaf..bfc73d83c 100644 --- a/pkg/models/kanban_task_bucket.go +++ b/pkg/models/kanban_task_bucket.go @@ -148,11 +148,17 @@ func updateTaskBucket(s *xorm.Session, a web.Auth, b *TaskBucket) (err error) { // 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 + // When no explicit default bucket is configured, preserve + // the task's original bucket so it remains in place. + if view.DefaultBucketID != 0 { + b.BucketID, err = getDefaultBucketID(s, view) + if err != nil { + return err + } + } else { + b.BucketID = oldTaskBucket.BucketID } - // If the task is already in the default bucket, skip the + // If the task is already in the correct 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. diff --git a/pkg/models/kanban_task_bucket_test.go b/pkg/models/kanban_task_bucket_test.go index 6d1eb2f24..032da7315 100644 --- a/pkg/models/kanban_task_bucket_test.go +++ b/pkg/models/kanban_task_bucket_test.go @@ -226,6 +226,61 @@ func TestTaskBucket_Update(t *testing.T) { }) }) + t.Run("moving a repeating task from a non-default bucket when no default bucket is configured preserves the original bucket", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + u := &user.User{ID: 1} + + // View 4 has default_bucket_id: 1 and done_bucket_id: 3. + // Remove the default bucket to test fallback behavior. + _, err := s.ID(4).Cols("default_bucket_id").Update(&ProjectView{DefaultBucketID: 0}) + require.NoError(t, err) + + // Task 28 is a repeating task. Pre-position it in bucket 2 + // using a raw update so we can bypass the bucket-2 limit check. + _, 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 back to the ORIGINAL bucket (2), not the default (1) + // and not left in the done bucket (3). + assert.Equal(t, int64(2), 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": 2, + }, false) + db.AssertMissing(t, "task_buckets", map[string]interface{}{ + "task_id": 28, + "bucket_id": 1, + }) + 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) u := &user.User{ID: 1} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index b7515b40a..06f937c74 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -1528,15 +1528,35 @@ func (t *Task) moveTaskToDoneBuckets(s *xorm.Session, a web.Auth, views []*Proje // 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. +// When no explicit default bucket is configured, the task is restored +// to its original bucket so it remains in its original workflow column. 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 + var bucketID int64 + var err error + + if view.DefaultBucketID != 0 { + bucketID = view.DefaultBucketID + } else { + // No explicit default: preserve the task's current bucket. + currentTaskBucket := &TaskBucket{} + _, err = s.Where("task_id = ? AND project_view_id = ?", t.ID, view.ID). + Get(currentTaskBucket) + if err != nil { + return err + } + bucketID = currentTaskBucket.BucketID + if bucketID == 0 { + // Task not in any bucket yet — fall back to first by position. + bucketID, err = getDefaultBucketID(s, view) + if err != nil { + return err + } + } } tb := &TaskBucket{ - BucketID: defaultBucketID, + BucketID: bucketID, TaskID: t.ID, ProjectViewID: view.ID, ProjectID: t.ProjectID, From e9d9c2ae4b5388618ef47a04d78f81da402f0bff Mon Sep 17 00:00:00 2001 From: nithinvarma411 Date: Sat, 30 May 2026 15:05:27 +0530 Subject: [PATCH 2/2] fix: skip bucket update for recurring tasks when no default bucket is set --- pkg/models/kanban_task_bucket.go | 12 ++++----- pkg/models/tasks.go | 46 +++++++++++--------------------- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/pkg/models/kanban_task_bucket.go b/pkg/models/kanban_task_bucket.go index bfc73d83c..235defd2e 100644 --- a/pkg/models/kanban_task_bucket.go +++ b/pkg/models/kanban_task_bucket.go @@ -148,8 +148,8 @@ func updateTaskBucket(s *xorm.Session, a web.Auth, b *TaskBucket) (err error) { // 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. - // When no explicit default bucket is configured, preserve - // the task's original bucket so it remains in place. + // When no default bucket is configured, leave the task in + // its current bucket — no update needed. if view.DefaultBucketID != 0 { b.BucketID, err = getDefaultBucketID(s, view) if err != nil { @@ -158,10 +158,10 @@ func updateTaskBucket(s *xorm.Session, a web.Auth, b *TaskBucket) (err error) { } else { b.BucketID = oldTaskBucket.BucketID } - // If the task is already in the correct 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 the bucket is unchanged, 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 06f937c74..1c488e096 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -1528,51 +1528,35 @@ func (t *Task) moveTaskToDoneBuckets(s *xorm.Session, a web.Auth, views []*Proje // 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. -// When no explicit default bucket is configured, the task is restored -// to its original bucket so it remains in its original workflow column. +// When no explicit default bucket is configured, the task stays in its +// current bucket — no update needed. func (t *Task) moveTaskToDefaultBuckets(s *xorm.Session, a web.Auth, views []*ProjectView) error { for _, view := range views { - var bucketID int64 - var err error - if view.DefaultBucketID != 0 { - bucketID = view.DefaultBucketID - } else { - // No explicit default: preserve the task's current bucket. - currentTaskBucket := &TaskBucket{} - _, err = s.Where("task_id = ? AND project_view_id = ?", t.ID, view.ID). - Get(currentTaskBucket) + defaultBucketID, err := getDefaultBucketID(s, view) if err != nil { return err } - bucketID = currentTaskBucket.BucketID - if bucketID == 0 { - // Task not in any bucket yet — fall back to first by position. - bucketID, err = getDefaultBucketID(s, view) - if err != nil { - return err - } + + tb := &TaskBucket{ + BucketID: defaultBucketID, + TaskID: t.ID, + ProjectViewID: view.ID, + ProjectID: t.ProjectID, + } + if err = updateTaskBucket(s, a, tb); err != nil { + return err } } - - tb := &TaskBucket{ - BucketID: bucketID, - TaskID: t.ID, - ProjectViewID: view.ID, - ProjectID: t.ProjectID, - } - err = updateTaskBucket(s, a, tb) - if err != nil { - return err - } + // When no default bucket is configured, the task stays in its current + // bucket — no bucket update needed. tp := TaskPosition{ TaskID: t.ID, ProjectViewID: view.ID, Position: calculateDefaultPosition(t.Index, t.Position), } - err = updateTaskPosition(s, a, &tp) - if err != nil { + if err := updateTaskPosition(s, a, &tp); err != nil { return err } }