Skip to content

Commit db92199

Browse files
authored
feat: add time to due date (#333)
* feat: add time to due date * fix: handle optional due date conversion and update tests
1 parent 5dc7387 commit db92199

9 files changed

Lines changed: 400 additions & 19 deletions

File tree

backend/controllers/add_task.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,10 @@ func AddTaskHandler(w http.ResponseWriter, r *http.Request) {
6666
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
6767
return
6868
}
69-
var dueDateStr string
70-
if dueDate != nil && *dueDate != "" {
71-
dueDateStr = *dueDate
69+
dueDateStr, err := utils.ConvertOptionalISOToTaskwarriorFormat(dueDate)
70+
if err != nil {
71+
http.Error(w, fmt.Sprintf("Invalid due date format: %v", err), http.StatusBadRequest)
72+
return
7273
}
7374

7475
logStore := models.GetLogStore()

backend/controllers/controllers_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ func Test_AddTaskHandler_WithDueDate(t *testing.T) {
135135
"description": "Test task",
136136
"project": "TestProject",
137137
"priority": "H",
138-
"due": "2025-12-31",
138+
"due": "2025-12-31T23:59:59.000Z",
139139
"tags": []string{"test", "important"},
140140
}
141141

backend/utils/datetime.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package utils
2+
3+
import (
4+
"fmt"
5+
"time"
6+
)
7+
8+
func ConvertISOToTaskwarriorFormat(isoDatetime string) (string, error) {
9+
if isoDatetime == "" {
10+
return "", nil
11+
}
12+
13+
// Try parsing the specific ISO formats we actually receive from frontend
14+
formats := []string{
15+
"2006-01-02T15:04:05.000Z", // "2025-12-27T14:30:00.000Z" (frontend datetime with milliseconds)
16+
"2006-01-02T15:04:05Z", // "2025-12-27T14:30:00Z" (datetime without milliseconds)
17+
"2006-01-02", // "2025-12-27" (date only)
18+
}
19+
20+
var parsedTime time.Time
21+
var err error
22+
var isDateOnly bool
23+
24+
for i, format := range formats {
25+
parsedTime, err = time.Parse(format, isoDatetime)
26+
if err == nil {
27+
// Check if it's date-only format (last format in array)
28+
isDateOnly = (i == 2) // "2006-01-02" format
29+
break
30+
}
31+
}
32+
33+
if err != nil {
34+
return "", fmt.Errorf("unable to parse datetime '%s': %v", isoDatetime, err)
35+
}
36+
37+
if isDateOnly {
38+
return parsedTime.Format("2006-01-02"), nil
39+
} else {
40+
return parsedTime.Format("2006-01-02T15:04:05"), nil
41+
}
42+
}
43+
func ConvertOptionalISOToTaskwarriorFormat(isoDatetime *string) (string, error) {
44+
if isoDatetime == nil || *isoDatetime == "" {
45+
return "", nil
46+
}
47+
return ConvertISOToTaskwarriorFormat(*isoDatetime)
48+
}

backend/utils/tw/taskwarrior_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func TestExportTasks(t *testing.T) {
4343
}
4444

4545
func TestAddTaskToTaskwarrior(t *testing.T) {
46-
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{})
46+
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{})
4747
if err != nil {
4848
t.Errorf("AddTaskToTaskwarrior failed: %v", err)
4949
} else {
@@ -52,7 +52,7 @@ func TestAddTaskToTaskwarrior(t *testing.T) {
5252
}
5353

5454
func TestAddTaskToTaskwarriorWithWaitDate(t *testing.T) {
55-
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{})
55+
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{})
5656
if err != nil {
5757
t.Errorf("AddTaskToTaskwarrior with wait date failed: %v", err)
5858
} else {
@@ -61,7 +61,7 @@ func TestAddTaskToTaskwarriorWithWaitDate(t *testing.T) {
6161
}
6262

6363
func TestAddTaskToTaskwarriorWithEntryDate(t *testing.T) {
64-
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)
64+
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{})
6565
if err != nil {
6666
t.Errorf("AddTaskToTaskwarrior failed: %v", err)
6767
} else {
@@ -79,7 +79,7 @@ func TestCompleteTaskInTaskwarrior(t *testing.T) {
7979
}
8080

8181
func TestAddTaskWithTags(t *testing.T) {
82-
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{})
82+
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{})
8383
if err != nil {
8484
t.Errorf("AddTaskToTaskwarrior with tags failed: %v", err)
8585
} else {
@@ -88,7 +88,7 @@ func TestAddTaskWithTags(t *testing.T) {
8888
}
8989

9090
func TestAddTaskToTaskwarriorWithEntryDateAndTags(t *testing.T) {
91-
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)
91+
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{})
9292
if err != nil {
9393
t.Errorf("AddTaskToTaskwarrior with entry date and tags failed: %v", err)
9494
} else {
@@ -97,7 +97,7 @@ func TestAddTaskToTaskwarriorWithEntryDateAndTags(t *testing.T) {
9797
}
9898

9999
func TestAddTaskToTaskwarriorWithWaitDateWithTags(t *testing.T) {
100-
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{})
100+
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{})
101101
if err != nil {
102102
t.Errorf("AddTaskToTaskwarrior with wait date failed: %v", err)
103103
} else {

backend/utils/utils_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,56 @@ func Test_ValidateDependencies_EmptyList(t *testing.T) {
9898
err := ValidateDependencies(depends, currentTaskUUID)
9999
assert.NoError(t, err)
100100
}
101+
102+
func TestConvertISOToTaskwarriorFormat(t *testing.T) {
103+
tests := []struct {
104+
name string
105+
input string
106+
expected string
107+
hasError bool
108+
}{
109+
{
110+
name: "ISO datetime with milliseconds (frontend format)",
111+
input: "2025-12-27T14:30:00.000Z",
112+
expected: "2025-12-27T14:30:00",
113+
hasError: false,
114+
},
115+
{
116+
name: "ISO datetime at midnight (explicit datetime)",
117+
input: "2025-12-27T00:00:00.000Z",
118+
expected: "2025-12-27T00:00:00",
119+
hasError: false,
120+
},
121+
{
122+
name: "Date only format",
123+
input: "2025-12-27",
124+
expected: "2025-12-27",
125+
hasError: false,
126+
},
127+
{
128+
name: "Empty string",
129+
input: "",
130+
expected: "",
131+
hasError: false,
132+
},
133+
{
134+
name: "Invalid format",
135+
input: "invalid-date",
136+
expected: "",
137+
hasError: true,
138+
},
139+
}
140+
141+
for _, tt := range tests {
142+
t.Run(tt.name, func(t *testing.T) {
143+
result, err := ConvertISOToTaskwarriorFormat(tt.input)
144+
145+
if tt.hasError {
146+
assert.Error(t, err)
147+
} else {
148+
assert.NoError(t, err)
149+
assert.Equal(t, tt.expected, result)
150+
}
151+
})
152+
}
153+
}

frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
22
import { Badge } from '@/components/ui/badge';
33
import { Button } from '@/components/ui/button';
44
import { DatePicker } from '@/components/ui/date-picker';
5+
import { DateTimePicker } from '@/components/ui/date-time-picker';
56
import {
67
Dialog,
78
DialogContent,
@@ -255,15 +256,27 @@ export const AddTaskdialog = ({
255256
Due
256257
</Label>
257258
<div className="col-span-3">
258-
<DatePicker
259-
date={newTask.due ? new Date(newTask.due) : undefined}
260-
onDateChange={(date) => {
259+
<DateTimePicker
260+
date={
261+
newTask.due
262+
? new Date(
263+
newTask.due.includes('T')
264+
? newTask.due
265+
: `${newTask.due}T00:00:00`
266+
)
267+
: undefined
268+
}
269+
onDateTimeChange={(date, hasTime) => {
261270
setNewTask({
262271
...newTask,
263-
due: date ? format(date, 'yyyy-MM-dd') : '',
272+
due: date
273+
? hasTime
274+
? date.toISOString()
275+
: format(date, 'yyyy-MM-dd')
276+
: '',
264277
});
265278
}}
266-
placeholder="Select a due date"
279+
placeholder="Select due date and time"
267280
/>
268281
</div>
269282
</div>

frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ describe('AddTaskDialog Component', () => {
367367
{ name: 'wait', label: 'Wait', placeholder: 'Select a wait date' },
368368
];
369369

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

381-
test.each(dateFields)(
381+
test('renders due date picker with correct placeholder', () => {
382+
mockProps.isOpen = true;
383+
render(<AddTaskdialog {...mockProps} />);
384+
385+
const dueDateButton = screen.getByText('Select due date and time');
386+
expect(dueDateButton).toBeInTheDocument();
387+
});
388+
389+
test.each(dateFields.filter((field) => field.name !== 'due'))(
382390
'updates $name when user selects a date',
383391
({ name, placeholder }) => {
384392
mockProps.isOpen = true;
@@ -394,7 +402,16 @@ describe('AddTaskDialog Component', () => {
394402
}
395403
);
396404

397-
test.each(dateFields)(
405+
// Special test for due date with DateTimePicker
406+
test('updates due when user selects a date and time', () => {
407+
mockProps.isOpen = true;
408+
render(<AddTaskdialog {...mockProps} />);
409+
410+
const dueDateButton = screen.getByText('Select due date and time');
411+
expect(dueDateButton).toBeInTheDocument();
412+
});
413+
414+
test.each(dateFields.filter((field) => field.name !== 'due'))(
398415
'allows empty $name date (optional field)',
399416
({ name, placeholder }) => {
400417
mockProps.isOpen = true;
@@ -413,6 +430,15 @@ describe('AddTaskDialog Component', () => {
413430
}
414431
);
415432

433+
// Special test for due date with DateTimePicker
434+
test('allows empty due date (optional field)', () => {
435+
mockProps.isOpen = true;
436+
render(<AddTaskdialog {...mockProps} />);
437+
438+
const dueDateButton = screen.getByText('Select due date and time');
439+
expect(dueDateButton).toBeInTheDocument();
440+
});
441+
416442
test.each(dateFields)(
417443
'submits task with $name date when provided',
418444
({ name }) => {

0 commit comments

Comments
 (0)