Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions backend/controllers/add_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"os"
)

var GlobalJobQueue *JobQueue
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 22 additions & 3 deletions backend/controllers/edit_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"os"
)

// EditTaskHandler godoc
Expand Down Expand Up @@ -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, taskUUID); 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, taskUUID, taskDeps); err != nil {
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
return
}
}

start, err = utils.ConvertISOToTaskwarriorFormat(start)
Expand Down
25 changes: 22 additions & 3 deletions backend/controllers/modify_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"os"
)

// ModifyTaskHandler godoc
Expand Down Expand Up @@ -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, taskUUID); 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, taskUUID, 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 {
Expand Down
6 changes: 4 additions & 2 deletions backend/utils/datetime.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ func ConvertISOToTaskwarriorFormat(isoDatetime string) (string, error) {
"2006-01-02T15:04:05.000Z", // "2025-12-27T14:30:00.000Z" (frontend datetime with milliseconds)
"2006-01-02T15:04:05Z", // "2025-12-27T14:30:00Z" (datetime without milliseconds)
"2006-01-02", // "2025-12-27" (date only)
"20060102T150405Z", // "20260128T000000Z" (compact ISO format)
"20060102", // "20260128" (compact date only)
}

var parsedTime time.Time
Expand All @@ -24,8 +26,8 @@ func ConvertISOToTaskwarriorFormat(isoDatetime string) (string, error) {
for i, format := range formats {
parsedTime, err = time.Parse(format, isoDatetime)
if err == nil {
// Check if it's date-only format (last format in array)
isDateOnly = (i == 2) // "2006-01-02" format
// Check if it's date-only format
isDateOnly = (i == 2 || i == 4) // "2006-01-02" or "20060102" formats
break
}
}
Expand Down
57 changes: 50 additions & 7 deletions backend/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,45 @@ 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"
err := ValidateDependencies(depends, currentTaskUUID)
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
Expand Down Expand Up @@ -136,6 +161,24 @@ func TestConvertISOToTaskwarriorFormat(t *testing.T) {
expected: "",
hasError: true,
},
{
name: "Compact ISO datetime format (Taskwarrior export)",
input: "20260128T000000Z",
expected: "2026-01-28T00:00:00",
hasError: false,
},
{
name: "Compact ISO datetime format with time",
input: "20260128T143000Z",
expected: "2026-01-28T14:30:00",
hasError: false,
},
{
name: "Compact date only format",
input: "20260128",
expected: "2026-01-28",
hasError: false,
},
}

for _, tt := range tests {
Expand Down
51 changes: 51 additions & 0 deletions backend/utils/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,54 @@ 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)
return dfsHasCycle(graph, startNode, color)
}

func dfsHasCycle(graph map[string][]string, node string, color map[string]int) bool {
if color[node] == 1 {
return true
}
if color[node] == 2 {
return false
}

color[node] = 1
for _, dep := range graph[node] {
if dfsHasCycle(graph, dep, color) {
return true
}
}
color[node] = 2
return false
}
59 changes: 39 additions & 20 deletions frontend/src/components/HomeComponents/Tasks/Tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ export const Tasks = (
setIsAddTaskOpen(false);
} catch (error) {
console.error('Failed to edit task:', error);
throw error;
}
}

Expand Down Expand Up @@ -655,28 +656,46 @@ export const Tasks = (
);
};

const handleDependsSaveClick = (task: Task, depends: string[]) => {
task.depends = depends;
const handleDependsSaveClick = async (task: Task, depends: string[]) => {
try {
setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid]));

setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid]));
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 save dependencies:', error);

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 || []
);
setUnsyncedTaskUuids((prev) => {
const newSet = new Set(prev);
newSet.delete(task.uuid);
return newSet;
});

toast.error('Failed to save dependencies. Please try again.', {
position: 'bottom-left',
autoClose: 3000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
});
}
};

const handleRecurSaveClick = (task: Task, recur: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const useEditTask = (selectedTask: Task | null) => {
editedRecur: selectedTask.recur || '',
originalRecur: selectedTask.recur || '',
editedAnnotations: selectedTask.annotations || [],
editedDepends: selectedTask.depends || [],
}));
}
}, [selectedTask]);
Expand Down
Loading