From a5746d00bcb3e86107fea389f8e70e3ad073e1c4 Mon Sep 17 00:00:00 2001 From: HarshPatel5940 Date: Wed, 10 Jun 2026 14:37:43 +0530 Subject: [PATCH 1/2] feat: blocked tasks blocking --- pkg/models/error.go | 36 ++++++ pkg/models/task_relation_test.go | 198 +++++++++++++++++++++++++++++++ pkg/models/tasks.go | 55 +++++++++ 3 files changed, 289 insertions(+) diff --git a/pkg/models/error.go b/pkg/models/error.go index 2f9d652c9..97f86bc01 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -2596,3 +2596,39 @@ func (err ErrTimeEntryEndBeforeStart) HTTPError() web.HTTPError { Message: "A time entry's end time cannot be before its start time.", } } + +// Task Blocking Errors + +// ErrTaskIsBlocked represents an error where a user tries to mark a blocked task as complete +// when the blocking task(s) are not yet complete. +type ErrTaskIsBlocked struct { + TaskID int64 + BlockingTaskIDs []int64 + BlockingTaskInfo []string // Human-readable info about blockers +} + +// IsErrTaskIsBlocked checks if an error is ErrTaskIsBlocked. +func IsErrTaskIsBlocked(err error) bool { + _, ok := err.(ErrTaskIsBlocked) + return ok +} + +func (err ErrTaskIsBlocked) Error() string { + if len(err.BlockingTaskInfo) == 1 { + return fmt.Sprintf("Cannot mark task as complete - blocked by: %s", err.BlockingTaskInfo[0]) + } + return fmt.Sprintf("Cannot mark task as complete - blocked by: %s", strings.Join(err.BlockingTaskInfo, ", ")) +} + +// ErrCodeTaskIsBlocked holds the unique world-error code of this error +const ErrCodeTaskIsBlocked = 20001 + +// HTTPError holds the http error description +func (err ErrTaskIsBlocked) HTTPError() web.HTTPError { + message := err.Error() + return web.HTTPError{ + HTTPCode: http.StatusConflict, + Code: ErrCodeTaskIsBlocked, + Message: message, + } +} diff --git a/pkg/models/task_relation_test.go b/pkg/models/task_relation_test.go index 09f5c3b7e..70c7e857c 100644 --- a/pkg/models/task_relation_test.go +++ b/pkg/models/task_relation_test.go @@ -420,3 +420,201 @@ func TestTaskRelation_CanCreate(t *testing.T) { assert.False(t, can) }) } + +// TestTaskBlockingEnforcement tests that blocked tasks cannot be completed if their blockers are not done +func TestTaskBlockingEnforcement(t *testing.T) { + t.Run("Task with no blockers can be marked complete", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + task := Task{ID: 1, Done: false} + err := task.Update(s, &user.User{ID: 1}) + require.NoError(t, err) + + task.Done = true + err = task.Update(s, &user.User{ID: 1}) + require.NoError(t, err) + + updated := Task{ID: 1} + err = updated.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + assert.True(t, updated.Done) + }) + + t.Run("Task with incomplete blocker cannot be marked complete", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Create blocking relation: task 3 blocks task 1 + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 3, + RelationKind: RelationKindBlocked, + } + err := rel.Create(s, &user.User{ID: 1}) + require.NoError(t, err) + + // Try to mark task 1 as complete while task 3 is not done + task := Task{ID: 1, Done: true} + err = task.Update(s, &user.User{ID: 1}) + require.Error(t, err) + assert.True(t, IsErrTaskIsBlocked(err)) + assert.Contains(t, err.Error(), "Cannot mark task as complete") + }) + + t.Run("Task with complete blocker can be marked complete", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Create blocking relation: task 3 blocks task 1 + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 3, + RelationKind: RelationKindBlocked, + } + err := rel.Create(s, &user.User{ID: 1}) + require.NoError(t, err) + + // Mark task 3 as complete (the blocker) + blocker := Task{ID: 3} + err = blocker.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + blocker.Done = true + err = blocker.Update(s, &user.User{ID: 1}) + require.NoError(t, err) + + // Now task 1 should be able to be marked complete + task := Task{ID: 1} + err = task.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + task.Done = true + err = task.Update(s, &user.User{ID: 1}) + require.NoError(t, err) + + updated := Task{ID: 1} + err = updated.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + assert.True(t, updated.Done) + }) + + t.Run("Multiple blockers - can only complete when all are done", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Create two blocking relations: task 3 and task 4 both block task 1 + rel1 := TaskRelation{ + TaskID: 1, + OtherTaskID: 3, + RelationKind: RelationKindBlocked, + } + err := rel1.Create(s, &user.User{ID: 1}) + require.NoError(t, err) + + rel2 := TaskRelation{ + TaskID: 1, + OtherTaskID: 4, + RelationKind: RelationKindBlocked, + } + err = rel2.Create(s, &user.User{ID: 1}) + require.NoError(t, err) + + // Mark task 3 as complete (one blocker done) + blocker1 := Task{ID: 3} + err = blocker1.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + blocker1.Done = true + err = blocker1.Update(s, &user.User{ID: 1}) + require.NoError(t, err) + + // Try to mark task 1 as complete - should still fail because task 4 is not done + task := Task{ID: 1, Done: true} + err = task.Update(s, &user.User{ID: 1}) + require.Error(t, err) + assert.True(t, IsErrTaskIsBlocked(err)) + + // Mark task 4 as complete (second blocker done) + blocker2 := Task{ID: 4} + err = blocker2.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + blocker2.Done = true + err = blocker2.Update(s, &user.User{ID: 1}) + require.NoError(t, err) + + // Now task 1 should be able to be marked complete + task = Task{ID: 1} + err = task.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + task.Done = true + err = task.Update(s, &user.User{ID: 1}) + require.NoError(t, err) + + updated := Task{ID: 1} + err = updated.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + assert.True(t, updated.Done) + }) + + t.Run("Error includes blocking task information", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Create blocking relation + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 3, + RelationKind: RelationKindBlocked, + } + err := rel.Create(s, &user.User{ID: 1}) + require.NoError(t, err) + + // Try to mark task 1 as complete + task := Task{ID: 1, Done: true} + err = task.Update(s, &user.User{ID: 1}) + require.Error(t, err) + assert.True(t, IsErrTaskIsBlocked(err)) + + // Error should contain task info + taskErr := err.(ErrTaskIsBlocked) + assert.Len(t, taskErr.BlockingTaskIDs, 1) + assert.Equal(t, int64(3), taskErr.BlockingTaskIDs[0]) + assert.NotEmpty(t, taskErr.BlockingTaskInfo) + assert.Contains(t, err.Error(), "ID: 3") + }) + + t.Run("Unmarking as done when blocked does not fail", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Create blocking relation: task 3 blocks task 1 + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 3, + RelationKind: RelationKindBlocked, + } + err := rel.Create(s, &user.User{ID: 1}) + require.NoError(t, err) + + // Task 1 is not done, try to unmark - should succeed + task := Task{ID: 1} + err = task.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + assert.False(t, task.Done) + + task.Done = false + err = task.Update(s, &user.User{ID: 1}) + require.NoError(t, err) + + // Verify still not done + updated := Task{ID: 1} + err = updated.ReadOne(s, &user.User{ID: 1}) + require.NoError(t, err) + assert.False(t, updated.Done) + }) + +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index c11854b93..f6824db0e 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -17,6 +17,7 @@ package models import ( + "fmt" "math" "regexp" "sort" @@ -1248,6 +1249,29 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e } } + // Check if task is being marked as complete (Done: false -> true) + // If so, verify there are no active blocking relations + if !ot.Done && t.Done { + blockingTasks, err := getActiveBlockingRelations(s, t.ID) + if err != nil { + return err + } + if len(blockingTasks) > 0 { + // Build informative error message with blocker details + blockingInfo := make([]string, 0, len(blockingTasks)) + blockingIDs := make([]int64, 0, len(blockingTasks)) + for _, blocker := range blockingTasks { + blockingInfo = append(blockingInfo, fmt.Sprintf("%s (ID: %d)", blocker.Title, blocker.ID)) + blockingIDs = append(blockingIDs, blocker.ID) + } + return ErrTaskIsBlocked{ + TaskID: t.ID, + BlockingTaskIDs: blockingIDs, + BlockingTaskInfo: blockingInfo, + } + } + } + // When a task was moved between projects, ensure it is in the correct bucket if t.ProjectID != ot.ProjectID { _, err = s.Where("task_id = ?", t.ID).Delete(&TaskBucket{}) @@ -2026,3 +2050,34 @@ func triggerTaskUpdatedEventForTaskID(s *xorm.Session, auth web.Auth, taskID int }) return nil } + +// getActiveBlockingRelations returns all tasks that are blocking the given task and are not yet complete. +// A task is actively blocking if it has a 'blocked' relation to an incomplete task. +func getActiveBlockingRelations(s *xorm.Session, taskID int64) ([]*Task, error) { + // Find all 'blocked' relations where this task is the blocked one + // i.e., this task -> other_task with relation_kind = 'blocked' + relations := []*TaskRelation{} + err := s.Where("task_id = ? AND relation_kind = ?", taskID, RelationKindBlocked).Find(&relations) + if err != nil { + return nil, err + } + + if len(relations) == 0 { + return nil, nil + } + + // Extract IDs of blocking tasks + blockingTaskIDs := make([]int64, 0, len(relations)) + for _, rel := range relations { + blockingTaskIDs = append(blockingTaskIDs, rel.OtherTaskID) + } + + // Get all blocking tasks that are NOT complete + blockingTasks := []*Task{} + err = s.In("id", blockingTaskIDs).Where("done = ?", false).Find(&blockingTasks) + if err != nil { + return nil, err + } + + return blockingTasks, nil +} From 3c8d6281d74b50569112bdec956ecebb5904bbc1 Mon Sep 17 00:00:00 2001 From: HarshPatel5940 Date: Wed, 10 Jun 2026 15:27:34 +0530 Subject: [PATCH 2/2] chore: addressing ui concerns --- .../tasks/partials/SingleTaskInProject.vue | 23 ++++++++++++++----- frontend/src/i18n/lang/en.json | 4 +++- frontend/src/views/tasks/TaskDetailView.vue | 18 +++++++++++++-- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/tasks/partials/SingleTaskInProject.vue b/frontend/src/components/tasks/partials/SingleTaskInProject.vue index 57a115b85..fa9dce3d1 100644 --- a/frontend/src/components/tasks/partials/SingleTaskInProject.vue +++ b/frontend/src/components/tasks/partials/SingleTaskInProject.vue @@ -13,12 +13,12 @@ @keyup.enter="openTaskDetail" > ( task.value.dueDate.getTime() <= now.value.getTime() )) +const isBlockedByIncomplete = computed(() => + task.value.relatedTasks?.[RELATION_KIND.BLOCKED]?.some(t => !t.done) ?? false +) + let oldTask async function markAsDone(checked: boolean, wasReverted: boolean = false) { const updateFunc = async () => { oldTask = {...task.value} - const newTask = await taskStore.update(task.value) - task.value = newTask + try { + const newTask = await taskStore.update(task.value) + task.value = newTask + } catch (e) { + task.value.done = !checked + error(e) + return + } updateDueDate() @@ -340,7 +351,7 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) { if (checked) { playPopSound() } - emit('taskUpdated', newTask) + emit('taskUpdated', task.value) let message = t('task.doneSuccess') if (!task.value.done && !isRepeating.value) { diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index a48d793ac..a1d70902f 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -939,6 +939,7 @@ "doneSuccess": "The task was successfully marked as done.", "undoneSuccess": "The task was successfully un-marked as done.", "readOnlyCheckbox": "You only have read access to this task and cannot mark it as done.", + "blockedCheckbox": "This task is blocked by incomplete tasks and cannot be marked as done.", "movedToProject": "The task was moved to {project}.", "undo": "Undo", "checklistTotal": "{checked} of {total} tasks", @@ -1457,7 +1458,8 @@ "13002": "The provided link share password is invalid.", "13003": "The provided link share token is invalid.", "14001": "The provided api token is invalid.", - "14002": "The permission {permission} of group {group} is invalid." + "14002": "The permission {permission} of group {group} is invalid.", + "20001": "This task is blocked by incomplete tasks and cannot be marked as done." }, "about": { "title": "About", diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index 5396bc797..946d87aa4 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -441,7 +441,9 @@