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
7 changes: 4 additions & 3 deletions backend/controllers/add_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ func AddTaskHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
return
}
var dueDateStr string
if dueDate != nil && *dueDate != "" {
dueDateStr = *dueDate
dueDateStr, err := utils.ConvertOptionalISOToTaskwarriorFormat(dueDate)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid due date format: %v", err), http.StatusBadRequest)
return
}

logStore := models.GetLogStore()
Expand Down
2 changes: 1 addition & 1 deletion backend/controllers/controllers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func Test_AddTaskHandler_WithDueDate(t *testing.T) {
"description": "Test task",
"project": "TestProject",
"priority": "H",
"due": "2025-12-31",
"due": "2025-12-31T23:59:59.000Z",
"tags": []string{"test", "important"},
}

Expand Down
48 changes: 48 additions & 0 deletions backend/utils/datetime.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package utils

import (
"fmt"
"time"
)

func ConvertISOToTaskwarriorFormat(isoDatetime string) (string, error) {
if isoDatetime == "" {
return "", nil
}

// Try parsing the specific ISO formats we actually receive from frontend
formats := []string{
"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)
}

var parsedTime time.Time
var err error
var isDateOnly bool

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
break
}
}

if err != nil {
return "", fmt.Errorf("unable to parse datetime '%s': %v", isoDatetime, err)
}

if isDateOnly {
return parsedTime.Format("2006-01-02"), nil
} else {
return parsedTime.Format("2006-01-02T15:04:05"), nil
}
}
func ConvertOptionalISOToTaskwarriorFormat(isoDatetime *string) (string, error) {
if isoDatetime == nil || *isoDatetime == "" {
return "", nil
}
return ConvertISOToTaskwarriorFormat(*isoDatetime)
}
12 changes: 6 additions & 6 deletions backend/utils/tw/taskwarrior_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestExportTasks(t *testing.T) {
}

func TestAddTaskToTaskwarrior(t *testing.T) {
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", "2025-03-01", "2025-03-01", "2025-03-01", "2025-03-03", "daily", nil, []models.Annotation{{Description: "note"}}, []string{})
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03T10:30:00", "2025-03-01", "2025-03-01", "2025-03-01", "2025-03-03", "daily", []string{}, []models.Annotation{{Description: "note"}}, []string{})
if err != nil {
t.Errorf("AddTaskToTaskwarrior failed: %v", err)
} else {
Expand All @@ -52,7 +52,7 @@ func TestAddTaskToTaskwarrior(t *testing.T) {
}

func TestAddTaskToTaskwarriorWithWaitDate(t *testing.T) {
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "project", "H", "2025-03-03", "2025-03-04", "2025-03-04", "2025-03-04", "2025-03-04", "", nil, []models.Annotation{})
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "project", "H", "2025-03-03T14:00:00", "2025-03-04", "2025-03-04", "2025-03-04", "2025-03-04", "", []string{}, []models.Annotation{}, []string{})
if err != nil {
t.Errorf("AddTaskToTaskwarrior with wait date failed: %v", err)
} else {
Expand All @@ -61,7 +61,7 @@ func TestAddTaskToTaskwarriorWithWaitDate(t *testing.T) {
}

func TestAddTaskToTaskwarriorWithEntryDate(t *testing.T) {
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "project", "H", "2025-03-05", "2025-03-04", "2025-03-04", "2025-03-04", "2025-03-10", "", nil, nil)
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "project", "H", "2025-03-05T16:30:00", "2025-03-04", "2025-03-04", "2025-03-04", "2025-03-10", "", []string{}, []models.Annotation{}, []string{})
if err != nil {
t.Errorf("AddTaskToTaskwarrior failed: %v", err)
} else {
Expand All @@ -79,7 +79,7 @@ func TestCompleteTaskInTaskwarrior(t *testing.T) {
}

func TestAddTaskWithTags(t *testing.T) {
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03", "2025-03-01", "2025-03-01", "2025-03-01", "2025-03-03", "daily", []string{"work", "important"}, []models.Annotation{{Description: "note"}}, []string{})
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "", "H", "2025-03-03T15:45:00", "2025-03-01", "2025-03-01", "2025-03-01", "2025-03-03", "daily", []string{"work", "important"}, []models.Annotation{{Description: "note"}}, []string{})
if err != nil {
t.Errorf("AddTaskToTaskwarrior with tags failed: %v", err)
} else {
Expand All @@ -88,7 +88,7 @@ func TestAddTaskWithTags(t *testing.T) {
}

func TestAddTaskToTaskwarriorWithEntryDateAndTags(t *testing.T) {
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "project", "H", "2025-03-05", "2025-03-04", "2025-03-04", "2025-03-04", "2025-03-10", "", []string{"work", "important"}, nil)
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "project", "H", "2025-03-05T16:00:00", "2025-03-04", "2025-03-04", "2025-03-04", "2025-03-10", "", []string{"work", "important"}, []models.Annotation{}, []string{})
if err != nil {
t.Errorf("AddTaskToTaskwarrior with entry date and tags failed: %v", err)
} else {
Expand All @@ -97,7 +97,7 @@ func TestAddTaskToTaskwarriorWithEntryDateAndTags(t *testing.T) {
}

func TestAddTaskToTaskwarriorWithWaitDateWithTags(t *testing.T) {
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "project", "H", "2025-03-03", "2025-03-04", "2025-03-04", "2025-03-04", "2025-03-04", "", []string{"work", "important"}, []models.Annotation{})
err := AddTaskToTaskwarrior("email", "encryption_secret", "clientId", "description", "project", "H", "2025-03-03T14:30:00", "2025-03-04", "2025-03-04", "2025-03-04", "2025-03-04", "", []string{"work", "important"}, []models.Annotation{}, []string{})
if err != nil {
t.Errorf("AddTaskToTaskwarrior with wait date failed: %v", err)
} else {
Expand Down
53 changes: 53 additions & 0 deletions backend/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,56 @@ func Test_ValidateDependencies_EmptyList(t *testing.T) {
err := ValidateDependencies(depends, currentTaskUUID)
assert.NoError(t, err)
}

func TestConvertISOToTaskwarriorFormat(t *testing.T) {
tests := []struct {
name string
input string
expected string
hasError bool
}{
{
name: "ISO datetime with milliseconds (frontend format)",
input: "2025-12-27T14:30:00.000Z",
expected: "2025-12-27T14:30:00",
hasError: false,
},
{
name: "ISO datetime at midnight (explicit datetime)",
input: "2025-12-27T00:00:00.000Z",
expected: "2025-12-27T00:00:00",
hasError: false,
},
{
name: "Date only format",
input: "2025-12-27",
expected: "2025-12-27",
hasError: false,
},
{
name: "Empty string",
input: "",
expected: "",
hasError: false,
},
{
name: "Invalid format",
input: "invalid-date",
expected: "",
hasError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ConvertISOToTaskwarriorFormat(tt.input)

if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
23 changes: 18 additions & 5 deletions frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import { DateTimePicker } from '@/components/ui/date-time-picker';
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -255,15 +256,27 @@ export const AddTaskdialog = ({
Due
</Label>
<div className="col-span-3">
<DatePicker
date={newTask.due ? new Date(newTask.due) : undefined}
onDateChange={(date) => {
<DateTimePicker
date={
newTask.due
? new Date(
newTask.due.includes('T')
? newTask.due
: `${newTask.due}T00:00:00`
)
: undefined
}
onDateTimeChange={(date, hasTime) => {
setNewTask({
...newTask,
due: date ? format(date, 'yyyy-MM-dd') : '',
due: date
? hasTime
? date.toISOString()
: format(date, 'yyyy-MM-dd')
: '',
});
}}
placeholder="Select a due date"
placeholder="Select due date and time"
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ describe('AddTaskDialog Component', () => {
{ name: 'wait', label: 'Wait', placeholder: 'Select a wait date' },
];

test.each(dateFields)(
test.each(dateFields.filter((field) => field.name !== 'due'))(
'renders $name date picker with correct placeholder',
({ placeholder }) => {
mockProps.isOpen = true;
Expand All @@ -378,7 +378,15 @@ describe('AddTaskDialog Component', () => {
}
);

test.each(dateFields)(
test('renders due date picker with correct placeholder', () => {
mockProps.isOpen = true;
render(<AddTaskdialog {...mockProps} />);

const dueDateButton = screen.getByText('Select due date and time');
expect(dueDateButton).toBeInTheDocument();
});

test.each(dateFields.filter((field) => field.name !== 'due'))(
'updates $name when user selects a date',
({ name, placeholder }) => {
mockProps.isOpen = true;
Expand All @@ -394,7 +402,16 @@ describe('AddTaskDialog Component', () => {
}
);

test.each(dateFields)(
// Special test for due date with DateTimePicker
test('updates due when user selects a date and time', () => {
mockProps.isOpen = true;
render(<AddTaskdialog {...mockProps} />);

const dueDateButton = screen.getByText('Select due date and time');
expect(dueDateButton).toBeInTheDocument();
});

test.each(dateFields.filter((field) => field.name !== 'due'))(
'allows empty $name date (optional field)',
({ name, placeholder }) => {
mockProps.isOpen = true;
Expand All @@ -413,6 +430,15 @@ describe('AddTaskDialog Component', () => {
}
);

// Special test for due date with DateTimePicker
test('allows empty due date (optional field)', () => {
mockProps.isOpen = true;
render(<AddTaskdialog {...mockProps} />);

const dueDateButton = screen.getByText('Select due date and time');
expect(dueDateButton).toBeInTheDocument();
});

test.each(dateFields)(
'submits task with $name date when provided',
({ name }) => {
Expand Down
Loading
Loading