diff --git a/backend/controllers/add_task.go b/backend/controllers/add_task.go index e44aa727..c684e8bf 100644 --- a/backend/controllers/add_task.go +++ b/backend/controllers/add_task.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "os" ) var GlobalJobQueue *JobQueue @@ -62,9 +63,27 @@ func AddTaskHandler(w http.ResponseWriter, r *http.Request) { } // Validate dependencies - if err := utils.ValidateDependencies(depends, ""); err != nil { - http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) - return + origin := os.Getenv("CONTAINER_ORIGIN") + existingTasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid) + if err != nil { + if err := utils.ValidateDependencies(depends, ""); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return + } + } else { + taskDeps := make([]utils.TaskDependency, len(existingTasks)) + for i, task := range existingTasks { + taskDeps[i] = utils.TaskDependency{ + UUID: task.UUID, + Depends: task.Depends, + Status: task.Status, + } + } + + if err := utils.ValidateCircularDependencies(depends, "", taskDeps); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return + } } dueDateStr, err := utils.ConvertOptionalISOToTaskwarriorFormat(dueDate) if err != nil { diff --git a/backend/controllers/edit_task.go b/backend/controllers/edit_task.go index f22cff37..8cd997fb 100644 --- a/backend/controllers/edit_task.go +++ b/backend/controllers/edit_task.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "os" ) // EditTaskHandler godoc @@ -62,9 +63,27 @@ func EditTaskHandler(w http.ResponseWriter, r *http.Request) { } // Validate dependencies - if err := utils.ValidateDependencies(depends, uuid); err != nil { - http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) - return + origin := os.Getenv("CONTAINER_ORIGIN") + existingTasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid) + if err != nil { + if err := utils.ValidateDependencies(depends, uuid); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return + } + } else { + taskDeps := make([]utils.TaskDependency, len(existingTasks)) + for i, task := range existingTasks { + taskDeps[i] = utils.TaskDependency{ + UUID: task.UUID, + Depends: task.Depends, + Status: task.Status, + } + } + + if err := utils.ValidateCircularDependencies(depends, uuid, taskDeps); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return + } } logStore := models.GetLogStore() diff --git a/backend/controllers/modify_task.go b/backend/controllers/modify_task.go index e3f9645a..605f5566 100644 --- a/backend/controllers/modify_task.go +++ b/backend/controllers/modify_task.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "os" ) // ModifyTaskHandler godoc @@ -61,9 +62,27 @@ func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) { } // Validate dependencies - if err := utils.ValidateDependencies(depends, uuid); err != nil { - http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) - return + origin := os.Getenv("CONTAINER_ORIGIN") + existingTasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid) + if err != nil { + if err := utils.ValidateDependencies(depends, uuid); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return + } + } else { + taskDeps := make([]utils.TaskDependency, len(existingTasks)) + for i, task := range existingTasks { + taskDeps[i] = utils.TaskDependency{ + UUID: task.UUID, + Depends: task.Depends, + Status: task.Status, + } + } + + if err := utils.ValidateCircularDependencies(depends, uuid, taskDeps); err != nil { + http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest) + return + } } // if err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID); err != nil { diff --git a/backend/utils/utils_test.go b/backend/utils/utils_test.go index 609bb9c8..90f0ed91 100644 --- a/backend/utils/utils_test.go +++ b/backend/utils/utils_test.go @@ -85,13 +85,6 @@ func Test_ExecCommandForOutputInDir(t *testing.T) { } } -func Test_ValidateDependencies_ValidDependencies(t *testing.T) { - depends := []string{"task-uuid-1", "task-uuid-2"} - currentTaskUUID := "current-task-uuid" - err := ValidateDependencies(depends, currentTaskUUID) - assert.NoError(t, err) -} - func Test_ValidateDependencies_EmptyList(t *testing.T) { depends := []string{} currentTaskUUID := "current-task-uuid" @@ -99,6 +92,38 @@ func Test_ValidateDependencies_EmptyList(t *testing.T) { assert.NoError(t, err) } +// Circular Dependency Detection Tests +func Test_detectCycle_NoCycle(t *testing.T) { //A -> B -> C + graph := map[string][]string{ + "A": {"B"}, + "B": {"C"}, + "C": {}, + } + + hasCycle := detectCycle(graph, "A") + assert.False(t, hasCycle, "Should not detect cycle in linear dependency") +} + +func Test_detectCycle_SimpleCycle(t *testing.T) { // A -> B -> A + graph := map[string][]string{ + "A": {"B"}, + "B": {"A"}, + } + + hasCycle := detectCycle(graph, "A") + assert.True(t, hasCycle, "Should detect simple cycle A -> B -> A") +} + +func Test_detectCycle_ComplexCycle(t *testing.T) { // A -> B -> C -> A + graph := map[string][]string{ + "A": {"B"}, + "B": {"C"}, + "C": {"A"}, + } + + hasCycle := detectCycle(graph, "A") + assert.True(t, hasCycle, "Should detect complex cycle A -> B -> C -> A") +} func TestConvertISOToTaskwarriorFormat(t *testing.T) { tests := []struct { name string diff --git a/backend/utils/validation.go b/backend/utils/validation.go index a439e055..2a570db0 100644 --- a/backend/utils/validation.go +++ b/backend/utils/validation.go @@ -19,3 +19,67 @@ func ValidateDependencies(depends []string, currentTaskUUID string) error { return nil } + +type TaskDependency struct { + UUID string `json:"uuid"` + Depends []string `json:"depends"` + Status string `json:"status"` +} + +func ValidateCircularDependencies(depends []string, currentTaskUUID string, existingTasks []TaskDependency) error { + if len(depends) == 0 { + return nil + } + + dependencyGraph := make(map[string][]string) + for _, task := range existingTasks { + if task.Status == "pending" { + dependencyGraph[task.UUID] = task.Depends + } + } + + dependencyGraph[currentTaskUUID] = depends + + if hasCycle := detectCycle(dependencyGraph, currentTaskUUID); hasCycle { + return fmt.Errorf("circular dependency detected: adding these dependencies would create a cycle") + } + + return nil +} + +// (0): unvisited, (1): visiting,(2): visited +func detectCycle(graph map[string][]string, startNode string) bool { + color := make(map[string]int) + + for node := range graph { + color[node] = 0 + } + + for _, deps := range graph { + for _, dep := range deps { + if _, exists := color[dep]; !exists { + color[dep] = 0 + } + } + } + + return dfsHasCycle(graph, startNode, color) +} + +func dfsHasCycle(graph map[string][]string, node string, color map[string]int) bool { + color[node] = 1 + + for _, dep := range graph[node] { + if color[dep] == 1 { + return true + } + if color[dep] == 0 { + if dfsHasCycle(graph, dep, color) { + return true + } + } + } + + color[node] = 2 + return false +} diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 773061db..c1858d18 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -229,6 +229,16 @@ export const Tasks = ( return () => clearInterval(interval); }, []); + // Listen for sync events from WebSocket handler + useEffect(() => { + const handleSyncTasks = () => { + syncTasksWithTwAndDb(); + }; + + window.addEventListener('syncTasks', handleSyncTasks); + return () => window.removeEventListener('syncTasks', handleSyncTasks); + }, [syncTasksWithTwAndDb]); + useEffect(() => { const fetchTasksForEmail = async () => { try { @@ -397,7 +407,7 @@ export const Tasks = ( annotations, }); - console.log('Task edited successfully!'); + // Don't show success message here - wait for WebSocket confirmation setIsAddTaskOpen(false); } catch (error) { console.error('Failed to edit task:', error); @@ -682,28 +692,31 @@ export const Tasks = ( ); }; - const handleDependsSaveClick = (task: Task, depends: string[]) => { - task.depends = depends; - + const handleDependsSaveClick = async (task: Task, depends: string[]) => { setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); - handleEditTaskOnBackend( - props.email, - props.encryptionSecret, - props.UUID, - task.description, - task.tags, - task.uuid.toString(), - task.project, - task.start, - task.entry || '', - task.wait || '', - task.end || '', - task.depends, - task.due || '', - task.recur || '', - task.annotations || [] - ); + try { + console.log('Calling backend...'); + await handleEditTaskOnBackend( + props.email, + props.encryptionSecret, + props.UUID, + task.description, + task.tags, + task.uuid.toString(), + task.project, + task.start, + task.entry || '', + task.wait || '', + task.end || '', + depends, + task.due || '', + task.recur || '', + task.annotations || [] + ); + } catch (error) { + console.error('Failed to update dependencies:', error); + } }; const handleRecurSaveClick = (task: Task, recur: string) => { diff --git a/frontend/src/components/HomeComponents/Tasks/UseEditTask.tsx b/frontend/src/components/HomeComponents/Tasks/UseEditTask.tsx index 7e4bbf40..97a08d67 100644 --- a/frontend/src/components/HomeComponents/Tasks/UseEditTask.tsx +++ b/frontend/src/components/HomeComponents/Tasks/UseEditTask.tsx @@ -34,7 +34,7 @@ export const useEditTask = (selectedTask: Task | null) => { annotationInput: '', }); - // Update edited tags when selected task changes + // Sync all editable fields from selectedTask when it changes useEffect(() => { if (selectedTask) { setState((prev) => ({ @@ -46,6 +46,7 @@ export const useEditTask = (selectedTask: Task | null) => { editedRecur: selectedTask.recur || '', originalRecur: selectedTask.recur || '', editedAnnotations: selectedTask.annotations || [], + editedDepends: selectedTask.depends || [], })); } }, [selectedTask]); diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index 68a2f0d7..6b472bc1 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -78,8 +78,12 @@ export const HomePage: React.FC = () => { try { const data = JSON.parse(event.data); if (data.status === 'success') { - // Skip refresh for Edit Task to prevent dialog blinking - if (data.job !== 'Edit Task') { + // Use syncTasksWithTwAndDb to update both backend and IndexedDB + // This ensures the Tasks component sees the updated data + if (data.job === 'Edit Task') { + // For Edit Task, we need to trigger a sync to update the UI + window.dispatchEvent(new CustomEvent('syncTasks')); + } else { getTasks(userInfo.email, userInfo.encryption_secret, userInfo.uuid); } @@ -128,7 +132,15 @@ export const HomePage: React.FC = () => { } } else if (data.status == 'failure') { console.log(`Failed to ${data.job || 'perform action'}`); - toast.error(`Failed to ${data.job || 'perform action'}`, { + + // Show specific message for Edit Task failures + let errorMessage = `Failed to ${data.job || 'perform action'}`; + if (data.job === 'Edit Task') { + errorMessage = + 'Failed to add dependency: Circular dependency detected'; + } + + toast.error(errorMessage, { position: 'bottom-left', autoClose: 3000, hideProgressBar: false,