Skip to content
Closed
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
10 changes: 6 additions & 4 deletions backend/controllers/add_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,19 @@ func AddTaskHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Description is required, and cannot be empty!", http.StatusBadRequest)
return
}
if dueDate == "" {
http.Error(w, "Due Date is required, and cannot be empty!", http.StatusBadRequest)
return

// Handle optional due date - convert pointer to string
var dueDateStr string
if dueDate != nil && *dueDate != "" {
dueDateStr = *dueDate
}

logStore := models.GetLogStore()
job := Job{
Name: "Add Task",
Execute: func() error {
logStore.AddLog("INFO", fmt.Sprintf("Adding task: %s", description), uuid, "Add Task")
err := tw.AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDate, tags)
err := tw.AddTaskToTaskwarrior(email, encryptionSecret, uuid, description, project, priority, dueDateStr, tags)
if err != nil {
logStore.AddLog("ERROR", fmt.Sprintf("Failed to add task: %v", err), uuid, "Add Task")
return err
Expand Down
74 changes: 74 additions & 0 deletions backend/controllers/controllers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package controllers

import (
"bytes"
"encoding/gob"
"encoding/json"
"net/http"
Expand Down Expand Up @@ -122,3 +123,76 @@ func Test_LogoutHandler(t *testing.T) {
session, _ := app.SessionStore.Get(req, "session-name")
assert.Equal(t, -1, session.Options.MaxAge)
}

func Test_AddTaskHandler_WithDueDate(t *testing.T) {
// Initialize job queue
GlobalJobQueue = NewJobQueue()

requestBody := map[string]interface{}{
"email": "test@example.com",
"encryptionSecret": "secret",
"UUID": "test-uuid",
"description": "Test task",
"project": "TestProject",
"priority": "H",
"due": "2025-12-31",
"tags": []string{"test", "important"},
}

body, _ := json.Marshal(requestBody)
req, err := http.NewRequest("POST", "/add-task", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")

rr := httptest.NewRecorder()
AddTaskHandler(rr, req)

assert.Equal(t, http.StatusAccepted, rr.Code)
}

func Test_AddTaskHandler_WithoutDueDate(t *testing.T) {
// Initialize job queue
GlobalJobQueue = NewJobQueue()

requestBody := map[string]interface{}{
"email": "test@example.com",
"encryptionSecret": "secret",
"UUID": "test-uuid",
"description": "Test task without due date",
"project": "TestProject",
"priority": "M",
"tags": []string{"test"},
}

body, _ := json.Marshal(requestBody)
req, err := http.NewRequest("POST", "/add-task", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")

rr := httptest.NewRecorder()
AddTaskHandler(rr, req)

assert.Equal(t, http.StatusAccepted, rr.Code)
}

func Test_AddTaskHandler_MissingDescription(t *testing.T) {
requestBody := map[string]interface{}{
"email": "test@example.com",
"encryptionSecret": "secret",
"UUID": "test-uuid",
"description": "",
"project": "TestProject",
"priority": "H",
}

body, _ := json.Marshal(requestBody)
req, err := http.NewRequest("POST", "/add-task", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")

rr := httptest.NewRecorder()
AddTaskHandler(rr, req)

assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "Description is required")
}
2 changes: 1 addition & 1 deletion backend/models/request_body.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type AddTaskRequestBody struct {
Description string `json:"description"`
Project string `json:"project"`
Priority string `json:"priority"`
DueDate string `json:"due"`
DueDate *string `json:"due"`
Tags []string `json:"tags"`
}
type ModifyTaskRequestBody struct {
Expand Down
9 changes: 9 additions & 0 deletions backend/utils/tw/taskwarrior_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ func TestAddTaskWithTags(t *testing.T) {
}
}

func TestAddTaskWithoutDueDate(t *testing.T) {
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "", nil)
if err != nil {
t.Errorf("AddTaskToTaskwarrior without due date failed: %v", err)
} else {
fmt.Println("Add task without due date passed")
}
}

func TestEditTaskWithTagAddition(t *testing.T) {
err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "+important"}, "project", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-30T18:30:00.000Z", nil, "2025-12-01T18:30:00.000Z")
if err != nil {
Expand Down
137 changes: 89 additions & 48 deletions frontend/src/components/HomeComponents/Tasks/Tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { DatePicker } from '@/components/ui/date-picker';
import { format } from 'date-fns';
import { Taskskeleton } from './TaskSkeleton';
import { Key } from '@/components/ui/key-button';
import { Switch } from '@/components/ui/switch';

const db = new TasksDatabase();
export let syncTasksWithTwAndDb: () => any;
Expand Down Expand Up @@ -104,6 +105,7 @@ export const Tasks = (
due: '',
tags: [] as string[],
});
const [includeDueDate, setIncludeDueDate] = useState(false);
const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);
const [isAddTaskOpen, setIsAddTaskOpen] = useState(false);
const [_isDialogOpen, setIsDialogOpen] = useState(false);
Expand Down Expand Up @@ -352,32 +354,36 @@ export const Tasks = (
due: string,
tags: string[]
) {
if (handleDate(newTask.due)) {
try {
await addTaskToBackend({
email,
encryptionSecret,
UUID,
description,
project,
priority,
due,
tags,
backendURL: url.backendURL,
});

console.log('Task added successfully!');
setNewTask({
description: '',
priority: '',
project: '',
due: '',
tags: [],
});
setIsAddTaskOpen(false);
} catch (error) {
console.error('Failed to add task:', error);
}
// Only validate due date if includeDueDate is checked and due is provided
if (includeDueDate && due && !handleDate(due)) {
return; // handleDate shows error toast, so just return
}

try {
await addTaskToBackend({
email,
encryptionSecret,
UUID,
description,
project,
priority,
due: includeDueDate ? due : undefined,
tags,
backendURL: url.backendURL,
});

console.log('Task added successfully!');
setNewTask({
description: '',
priority: '',
project: '',
due: '',
tags: [],
});
setIncludeDueDate(false);
setIsAddTaskOpen(false);
} catch (error) {
console.error('Failed to add task:', error);
}
}

Expand Down Expand Up @@ -1016,7 +1022,20 @@ export const Tasks = (
<div className="pr-2">
<Dialog
open={isAddTaskOpen}
onOpenChange={setIsAddTaskOpen}
onOpenChange={(open) => {
setIsAddTaskOpen(open);
if (!open) {
// Reset form when dialog closes
setIncludeDueDate(false);
setNewTask({
description: '',
priority: '',
project: '',
due: '',
tags: [],
});
}
}}
>
<DialogTrigger asChild>
<Button
Expand Down Expand Up @@ -1097,11 +1116,11 @@ export const Tasks = (
<Select
value={
isCreatingNewProject
? ''
: newTask.project || ''
? '__create__'
: newTask.project || 'None'
}
onValueChange={(value: string) => {
if (value === '') {
if (value === '__create__') {
// User selected "create new project" option
setIsCreatingNewProject(true);
setNewTask({ ...newTask, project: '' });
Expand Down Expand Up @@ -1135,7 +1154,7 @@ export const Tasks = (
</SelectItem>
))}
<SelectItem
value=""
value="__create__"
data-testid="project-option-create"
>
+ Create new project…
Expand All @@ -1162,28 +1181,50 @@ export const Tasks = (
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="due" className="text-right">
Due
<Label
htmlFor="includeDueDate"
className="text-right"
>
Due Date
</Label>
<div className="col-span-3">
<DatePicker
date={
newTask.due
? new Date(newTask.due)
: undefined
}
onDateChange={(date) => {
setNewTask({
...newTask,
due: date
? format(date, 'yyyy-MM-dd')
: '',
});
<div className="col-span-3 flex items-center justify-start">
<Switch
id="includeDueDate"
checked={includeDueDate}
onCheckedChange={(val: boolean) => {
setIncludeDueDate(val);
if (!val) {
setNewTask({ ...newTask, due: '' });
}
}}
placeholder="Select a due date"
/>
</div>
</div>
{includeDueDate && (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="due" className="text-right">
Due
</Label>
<div className="col-span-3">
<DatePicker
date={
newTask.due
? new Date(newTask.due)
: undefined
}
onDateChange={(date) => {
setNewTask({
...newTask,
due: date
? format(date, 'yyyy-MM-dd')
: '',
});
}}
placeholder="Select a due date"
/>
</div>
</div>
)}
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="description"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ describe('Tasks Component', () => {
fireEvent.click(screen.getByRole('button', { name: /add task/i }));

const projectSelect = await screen.findByTestId('project-select');
fireEvent.change(projectSelect, { target: { value: '' } }); // Empty string triggers "create new project" mode
fireEvent.change(projectSelect, { target: { value: '__create__' } });

const newProjectInput =
await screen.findByPlaceholderText('New project name');
Expand All @@ -485,4 +485,56 @@ describe('Tasks Component', () => {

expect(newProjectInput).toHaveValue('My Fresh Project');
});

test('shows Due Date switch and it is off by default', async () => {
render(<Tasks {...mockProps} />);

fireEvent.click(screen.getByRole('button', { name: /add task/i }));

const dueDateSwitch = await screen.findByLabelText(/due/i);
expect(dueDateSwitch).toBeInTheDocument();
expect(dueDateSwitch).not.toBeChecked();

expect(screen.queryByText('Due')).not.toBeInTheDocument();
});

test('toggling switch shows and hides date picker', async () => {
render(<Tasks {...mockProps} />);

fireEvent.click(screen.getByRole('button', { name: /add task/i }));

const dueDateSwitch = await screen.findByLabelText(/due/i);

act(() => {
fireEvent.click(dueDateSwitch);
});

expect(dueDateSwitch).toBeChecked();
expect(await screen.findByText('Due')).toBeInTheDocument();

act(() => {
fireEvent.click(dueDateSwitch);
});

expect(dueDateSwitch).not.toBeChecked();
expect(screen.queryByText('Due')).not.toBeInTheDocument();
});
test('submitting without toggling switch does not send due field', async () => {
const { addTaskToBackend } = require('../hooks');
addTaskToBackend.mockClear();

render(<Tasks {...mockProps} />);

fireEvent.click(screen.getByRole('button', { name: /add task/i }));

const descriptionInput = screen.getByLabelText(/description/i);
fireEvent.change(descriptionInput, { target: { value: 'Test task' } });

fireEvent.click(screen.getByRole('button', { name: /^add task$/i }));

await waitFor(() => expect(addTaskToBackend).toHaveBeenCalled());

const args = addTaskToBackend.mock.calls[0][0];
expect(args.due).toBeUndefined(); // switch was OFF, so no due
});
});
Loading
Loading