diff --git a/backend/controllers/add_task.go b/backend/controllers/add_task.go index bd372136..8b9216a7 100644 --- a/backend/controllers/add_task.go +++ b/backend/controllers/add_task.go @@ -47,6 +47,13 @@ func AddTaskHandler(w http.ResponseWriter, r *http.Request) { priority := requestBody.Priority dueDate := requestBody.DueDate start := requestBody.Start + end := requestBody.End + + // Validate start/end ordering + if err := validateStartEnd(start, end); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } recur := requestBody.Recur tags := requestBody.Tags annotations := requestBody.Annotations diff --git a/backend/controllers/edit_task.go b/backend/controllers/edit_task.go index 8ef0e5b6..a58ebaa7 100644 --- a/backend/controllers/edit_task.go +++ b/backend/controllers/edit_task.go @@ -59,6 +59,12 @@ func EditTaskHandler(w http.ResponseWriter, r *http.Request) { return } + // Validate start/end ordering + if err := validateStartEnd(start, end); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + logStore := models.GetLogStore() job := Job{ Name: "Edit Task", diff --git a/backend/controllers/modify_task.go b/backend/controllers/modify_task.go index 573d97a4..32c29320 100644 --- a/backend/controllers/modify_task.go +++ b/backend/controllers/modify_task.go @@ -47,6 +47,8 @@ func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) { priority := requestBody.Priority status := requestBody.Status due := requestBody.Due + start := requestBody.Start + end := requestBody.End tags := requestBody.Tags if description == "" { @@ -58,6 +60,13 @@ func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) { return } + // Validate start/end ordering if provided + if err := validateStartEnd(start, end); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // requestBody currently doesn't include start field for ModifyTaskRequestBody + // if err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID); err != nil { // http.Error(w, err.Error(), http.StatusInternalServerError) // return @@ -68,7 +77,7 @@ func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) { Name: "Modify Task", Execute: func() error { logStore.AddLog("INFO", fmt.Sprintf("Modifying task ID: %s", taskID), uuid, "Modify Task") - err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID, tags) + err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, end, email, encryptionSecret, taskID, tags) if err != nil { logStore.AddLog("ERROR", fmt.Sprintf("Failed to modify task ID %s: %v", taskID, err), uuid, "Modify Task") return err diff --git a/backend/controllers/validation.go b/backend/controllers/validation.go new file mode 100644 index 00000000..a9b267d0 --- /dev/null +++ b/backend/controllers/validation.go @@ -0,0 +1,63 @@ +package controllers + +import ( + "fmt" + "time" +) + +var dateLayouts = []string{ + time.RFC3339, + "2006-01-02T15:04:05.000Z", + "2006-01-02T15:04:05", + "2006-01-02", +} + +// validateStartEnd returns an error if both start and end are provided and end is before start. +func validateStartEnd(start, end string) error { + if start == "" || end == "" { + return nil + } + + var sTime, eTime time.Time + var err error + + // try multiple layouts for parsing + for _, l := range dateLayouts { + if sTime.IsZero() { + sTime, err = time.Parse(l, start) + if err == nil { + break + } + } + } + if sTime.IsZero() { + // as a last resort try Date constructor via RFC3339 parsing of date-only + if t, err2 := time.Parse("2006-01-02", start); err2 == nil { + sTime = t + } + } + + for _, l := range dateLayouts { + if eTime.IsZero() { + eTime, err = time.Parse(l, end) + if err == nil { + break + } + } + } + if eTime.IsZero() { + if t, err2 := time.Parse("2006-01-02", end); err2 == nil { + eTime = t + } + } + + // If either failed to parse, skip strict validation + if sTime.IsZero() || eTime.IsZero() { + return nil + } + + if eTime.Before(sTime) { + return fmt.Errorf("end must be greater than or equal to start") + } + return nil +} diff --git a/backend/controllers/validation_test.go b/backend/controllers/validation_test.go new file mode 100644 index 00000000..fbf8eea7 --- /dev/null +++ b/backend/controllers/validation_test.go @@ -0,0 +1,43 @@ +package controllers + +import ( + "strings" + "testing" +) + +func TestValidateStartEnd_Table(t *testing.T) { + tests := []struct { + name string + start string + end string + wantErr bool + }{ + {name: "equal date-only", start: "2025-01-01", end: "2025-01-01", wantErr: false}, + {name: "end after start date-only", start: "2025-01-01", end: "2025-01-02", wantErr: false}, + {name: "end before start date-only", start: "2025-01-02", end: "2025-01-01", wantErr: true}, + {name: "RFC3339 times valid", start: "2025-01-01T10:00:00Z", end: "2025-01-01T12:00:00Z", wantErr: false}, + {name: "RFC3339 times invalid (end before)", start: "2025-01-01T12:00:00Z", end: "2025-01-01T10:00:00Z", wantErr: true}, + {name: "millisecond layout valid", start: "2025-01-01T08:00:00.000Z", end: "2025-01-01T09:00:00.000Z", wantErr: false}, + {name: "unparsable inputs skip validation", start: "not-a-date", end: "also-not", wantErr: false}, + {name: "start unparsable end parseable skip", start: "bad", end: "2025-01-02", wantErr: false}, + {name: "end unparsable start parseable skip", start: "2025-01-02", end: "bad", wantErr: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateStartEnd(tc.start, tc.end) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error but got nil for start=%q end=%q", tc.start, tc.end) + } + if !strings.Contains(err.Error(), "end must") { + t.Fatalf("unexpected error message: %v", err) + } + } else { + if err != nil { + t.Fatalf("expected no error but got: %v for start=%q end=%q", err, tc.start, tc.end) + } + } + }) + } +} diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index e1be1ce2..b80f83a8 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -399,6 +399,12 @@ "project": { "type": "string" }, + "start": { + "type": "string" + }, + "end": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -476,6 +482,12 @@ "taskid": { "type": "string" } + "start": { + "type": "string" + }, + "end": { + "type": "string" + } } }, "models.ModifyTaskRequestBody": { @@ -514,6 +526,13 @@ "taskid": { "type": "string" } + , + "start": { + "type": "string" + }, + "end": { + "type": "string" + } } }, "models.Task": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 3327a721..2552c4e6 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -16,6 +16,10 @@ definitions: type: string project: type: string + start: + type: string + end: + type: string tags: items: type: string @@ -66,6 +70,10 @@ definitions: type: array taskid: type: string + start: + type: string + end: + type: string type: object models.ModifyTaskRequestBody: properties: @@ -91,6 +99,10 @@ definitions: type: array taskid: type: string + start: + type: string + end: + type: string type: object models.Task: properties: diff --git a/backend/models/request_body.go b/backend/models/request_body.go index 8579239c..39f7738b 100644 --- a/backend/models/request_body.go +++ b/backend/models/request_body.go @@ -10,6 +10,7 @@ type AddTaskRequestBody struct { Priority string `json:"priority"` DueDate *string `json:"due"` Start string `json:"start"` + End string `json:"end,omitempty"` Recur string `json:"recur"` Tags []string `json:"tags"` Annotations []Annotation `json:"annotations"` @@ -24,6 +25,8 @@ type ModifyTaskRequestBody struct { Priority string `json:"priority"` Status string `json:"status"` Due string `json:"due"` + Start string `json:"start"` + End string `json:"end"` Tags []string `json:"tags"` } type EditTaskRequestBody struct { diff --git a/backend/utils/tw/add_task.go b/backend/utils/tw/add_task.go index 79fe4061..89623862 100644 --- a/backend/utils/tw/add_task.go +++ b/backend/utils/tw/add_task.go @@ -10,7 +10,8 @@ import ( ) // add task to the user's tw client -func AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDate, start, recur string, tags []string, annotations []models.Annotation) error { +func AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDate, start, end, recur string, tags []string, annotations []models.Annotation) error { + if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { return fmt.Errorf("error deleting Taskwarrior data: %v", err) } @@ -43,6 +44,8 @@ func AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, p if start != "" { cmdArgs = append(cmdArgs, "start:"+start) } + if end != "" { + cmdArgs = append(cmdArgs, "end:"+end) // Note: Taskwarrior requires a due date to be set before recur can be set // Only add recur if dueDate is also provided if recur != "" && dueDate != "" { diff --git a/backend/utils/tw/modify_task.go b/backend/utils/tw/modify_task.go index 286a435e..fb37ce76 100644 --- a/backend/utils/tw/modify_task.go +++ b/backend/utils/tw/modify_task.go @@ -7,7 +7,7 @@ import ( "strings" ) -func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID string, tags []string) error { +func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, end, email, encryptionSecret, taskID string, tags []string) error { if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { fmt.Println("1") return fmt.Errorf("error deleting Taskwarrior data: %v", err) @@ -55,6 +55,14 @@ func ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, return fmt.Errorf("failed to edit task due: %v", err) } + // Handle end date + if end != "" { + escapedEnd := fmt.Sprintf(`end:%s`, strings.ReplaceAll(end, `"`, `\"`)) + if err := utils.ExecCommand("task", taskID, "modify", escapedEnd); err != nil { + return fmt.Errorf("failed to edit task end: %v", err) + } + } + // escapedStatus := fmt.Sprintf(`status:%s`, strings.ReplaceAll(status, `"`, `\"`)) if status == "completed" { utils.ExecCommand("task", taskID, "done", "rc.confirmation=off") diff --git a/backend/utils/tw/taskwarrior_test.go b/backend/utils/tw/taskwarrior_test.go index f08fbb18..7ef518a6 100644 --- a/backend/utils/tw/taskwarrior_test.go +++ b/backend/utils/tw/taskwarrior_test.go @@ -96,7 +96,7 @@ func TestEditTaskWithMixedTagOperations(t *testing.T) { } func TestModifyTaskWithTags(t *testing.T) { - err := ModifyTaskInTaskwarrior("uuid", "description", "project", "H", "pending", "2025-03-03", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"}) + err := ModifyTaskInTaskwarrior("uuid", "description", "project", "H", "pending", "2025-03-03", "", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"}) if err != nil { t.Errorf("ModifyTaskInTaskwarrior with tags failed: %v", err) } else { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0193ba48..432e17f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6317,9 +6317,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001695", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz", - "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==", + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", "funding": [ { "type": "opencollective", diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index 007ff6f4..0134a8de 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -254,6 +254,20 @@ export const AddTaskdialog = ({
+ +
+ { + setNewTask({ + ...newTask, + end: date ? format(date, 'yyyy-MM-dd') : '', + }); + }} + placeholder="Select an end date" + /> diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index b22b6c8f..ac417a28 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -73,6 +73,7 @@ export const Tasks = ( project: '', due: '', start: '', + end: '', recur: '', tags: [], annotations: [], @@ -309,6 +310,7 @@ export const Tasks = ( priority: task.priority, due: task.due || undefined, start: task.start || '', + end: task.end || undefined, recur: task.recur || '', tags: task.tags, annotations: task.annotations, @@ -322,6 +324,7 @@ export const Tasks = ( project: '', due: '', start: '', + end: '', recur: '', tags: [], annotations: [], diff --git a/frontend/src/components/HomeComponents/Tasks/hooks.ts b/frontend/src/components/HomeComponents/Tasks/hooks.ts index 27935c71..04f1c915 100644 --- a/frontend/src/components/HomeComponents/Tasks/hooks.ts +++ b/frontend/src/components/HomeComponents/Tasks/hooks.ts @@ -43,6 +43,7 @@ export const addTaskToBackend = async ({ priority, due, start, + end, recur, tags, annotations, @@ -56,6 +57,7 @@ export const addTaskToBackend = async ({ priority: string; due?: string; start: string; + end?: string; recur: string; tags: string[]; annotations: { entry: string; description: string }[]; @@ -81,6 +83,9 @@ export const addTaskToBackend = async ({ requestBody.start = start; } + // Only include end if it's provided + if (end !== undefined && end !== '') { + requestBody.end = end; // Only include recur if it's provided if (recur !== undefined && recur !== '') { requestBody.recur = recur; diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index c0ab04d1..7925945a 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -100,6 +100,7 @@ export interface TaskFormData { project: string; due: string; start: string; + end: string; recur: string; tags: string[]; annotations: Annotation[];