diff --git a/backend/controllers/add_task.go b/backend/controllers/add_task.go index e0510162..e44aa727 100644 --- a/backend/controllers/add_task.go +++ b/backend/controllers/add_task.go @@ -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() diff --git a/backend/controllers/controllers_test.go b/backend/controllers/controllers_test.go index 16127919..cd289aa4 100644 --- a/backend/controllers/controllers_test.go +++ b/backend/controllers/controllers_test.go @@ -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"}, } diff --git a/backend/utils/datetime.go b/backend/utils/datetime.go new file mode 100644 index 00000000..90d148d0 --- /dev/null +++ b/backend/utils/datetime.go @@ -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) +} diff --git a/backend/utils/tw/taskwarrior_test.go b/backend/utils/tw/taskwarrior_test.go index 6c69d581..fb15013d 100644 --- a/backend/utils/tw/taskwarrior_test.go +++ b/backend/utils/tw/taskwarrior_test.go @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { diff --git a/backend/utils/utils_test.go b/backend/utils/utils_test.go index b8fe26f3..609bb9c8 100644 --- a/backend/utils/utils_test.go +++ b/backend/utils/utils_test.go @@ -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) + } + }) + } +} diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index 29309864..485f6c5b 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -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, @@ -255,15 +256,27 @@ export const AddTaskdialog = ({ Due
- { + { 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" />
diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx index d1f875c1..4f1d803c 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx @@ -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; @@ -378,7 +378,15 @@ describe('AddTaskDialog Component', () => { } ); - test.each(dateFields)( + test('renders due date picker with correct placeholder', () => { + mockProps.isOpen = true; + render(); + + 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; @@ -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(); + + 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; @@ -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(); + + const dueDateButton = screen.getByText('Select due date and time'); + expect(dueDateButton).toBeInTheDocument(); + }); + test.each(dateFields)( 'submits task with $name date when provided', ({ name }) => { diff --git a/frontend/src/components/ui/date-time-picker.tsx b/frontend/src/components/ui/date-time-picker.tsx new file mode 100644 index 00000000..45ffd6d1 --- /dev/null +++ b/frontend/src/components/ui/date-time-picker.tsx @@ -0,0 +1,234 @@ +'use client'; + +import * as React from 'react'; +import { CalendarIcon } from '@radix-ui/react-icons'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +interface DateTimePickerProps { + date: Date | undefined; + onDateTimeChange: (date: Date | undefined, hasTime?: boolean) => void; + placeholder?: string; + className?: string; +} + +export function DateTimePicker({ + date, + onDateTimeChange, + placeholder = 'Pick a date', + className, +}: DateTimePickerProps) { + const [isOpen, setIsOpen] = React.useState(false); + const [internalDate, setInternalDate] = React.useState( + date + ); + const [hasTime, setHasTime] = React.useState(false); + const isInternalUpdate = React.useRef(false); + + // Update internal date when prop changes (but not from our own updates) + React.useEffect(() => { + if (!isInternalUpdate.current) { + setInternalDate(date); + setHasTime(false); // Only reset hasTime for external updates + } + isInternalUpdate.current = false; + }, [date]); + + const hours = Array.from({ length: 12 }, (_, i) => i + 1); + + const handleDateSelect = (selectedDate: Date | undefined) => { + if (selectedDate) { + // Create a new date using the local date components to avoid timezone issues + const newDate = new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + selectedDate.getDate(), + 0, + 0, + 0, + 0 + ); + setInternalDate(newDate); + + // Mark as internal update and send the local date object directly with hasTime = false + isInternalUpdate.current = true; + onDateTimeChange(newDate, false); + } else { + setInternalDate(undefined); + setHasTime(false); + isInternalUpdate.current = true; + onDateTimeChange(undefined, false); + } + }; + + const handleTimeChange = ( + type: 'hour' | 'minute' | 'ampm', + value: string + ) => { + // Prevent time selection if no date is selected + if (!internalDate) { + return; + } + + // Mark that user has explicitly selected time + setHasTime(true); + + const newDate = new Date(internalDate); + + if (type === 'hour') { + const hour = parseInt(value); + const currentHours = newDate.getHours(); + const isPM = currentHours >= 12; + + if (hour === 12) { + // 12 AM = 0, 12 PM = 12 + newDate.setHours(isPM ? 12 : 0); + } else { + // 1-11 AM = 1-11, 1-11 PM = 13-23 + newDate.setHours(isPM ? hour + 12 : hour); + } + } else if (type === 'minute') { + newDate.setMinutes(parseInt(value)); + } else if (type === 'ampm') { + const currentHours = newDate.getHours(); + if (value === 'PM' && currentHours < 12) { + newDate.setHours(currentHours + 12); + } else if (value === 'AM' && currentHours >= 12) { + newDate.setHours(currentHours - 12); + } + } + + setInternalDate(newDate); + // Mark as internal update and send full datetime when time is explicitly selected + isInternalUpdate.current = true; + onDateTimeChange(newDate, true); + }; + + const getCurrentHour12 = () => { + if (!internalDate || !hasTime) return 12; + const hours = internalDate.getHours(); + if (hours === 0) return 12; + if (hours > 12) return hours - 12; + return hours; + }; + + const getCurrentMinutes = () => { + if (!internalDate || !hasTime) return 0; + return internalDate.getMinutes(); + }; + + const getCurrentAMPM = () => { + if (!internalDate || !hasTime) return 'AM'; + return internalDate.getHours() >= 12 ? 'PM' : 'AM'; + }; + + return ( + + + + + e.preventDefault()} + > +
+ +
+
+
+ {hours.map((hour) => ( + + ))} +
+
+
+
+ {Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => ( + + ))} +
+
+
+
+ {['AM', 'PM'].map((ampm) => ( + + ))} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/utils/ExportTasks.tsx b/frontend/src/components/utils/ExportTasks.tsx index 2678106e..6a8bb10f 100644 --- a/frontend/src/components/utils/ExportTasks.tsx +++ b/frontend/src/components/utils/ExportTasks.tsx @@ -35,7 +35,13 @@ export function exportTasksAsTXT(tasks: Task[]) { txtContent += `Tags: ${task.tags.length ? task.tags.join(', ') : 'None'}\n`; txtContent += `UUID: ${task.uuid}\n`; txtContent += `Entry: ${new Date(task.entry).toLocaleString()}\n`; - txtContent += `Due: ${task.due ? new Date(task.due).toLocaleString() : 'None'}\n`; + txtContent += `Due: ${ + task.due + ? new Date( + task.due.includes('T') ? task.due : `${task.due}T00:00:00` + ).toLocaleString() + : 'None' + }\n`; txtContent += `----------------------------------------\n\n`; });