diff options
| -rw-r--r-- | internal/task/sort.go | 99 | ||||
| -rw-r--r-- | internal/task/sort_test.go | 119 | ||||
| -rw-r--r-- | internal/task/task.go | 96 |
3 files changed, 218 insertions, 96 deletions
diff --git a/internal/task/sort.go b/internal/task/sort.go new file mode 100644 index 0000000..7cd953b --- /dev/null +++ b/internal/task/sort.go @@ -0,0 +1,99 @@ +package task + +import ( + "sort" + "strings" + "time" +) + +// SortTasks orders tasks by start status, priority, due date, tag names and id. +// Started tasks are always placed before non-started ones. Tasks without a due +// date are placed after tasks with a due date. Overdue tasks are placed at the +// very top regardless of other properties. +// +// The sort order is: +// 1. Overdue tasks (oldest due date first) +// 2. Started tasks (not completed) +// 3. High priority tasks +// 4. Tasks with earlier due dates +// 5. Tasks sorted alphabetically by tags +// 6. Tasks sorted by ID (oldest first) +func SortTasks(tasks []Task) { + now := time.Now() + + sort.Slice(tasks, func(i, j int) bool { + ti, tj := tasks[i], tasks[j] + + if oi, oj := isOverdue(ti, now), isOverdue(tj, now); oi != oj { + return oi + } + + startedI := ti.Start != "" && ti.Status != "completed" + startedJ := tj.Start != "" && tj.Status != "completed" + if startedI != startedJ { + return startedI + } + + pi, pj := priorityRank(ti.Priority), priorityRank(tj.Priority) + if pi != pj { + return pi > pj + } + + di, iok := parseDueDate(ti.Due) + dj, jok := parseDueDate(tj.Due) + if iok && !jok { + return true + } + if !iok && jok { + return false + } + if iok && jok && !di.Equal(dj) { + return di.Before(dj) + } + + tgI, tgJ := joinTags(ti.Tags), joinTags(tj.Tags) + if tgI != tgJ { + return tgI < tgJ + } + + return ti.ID < tj.ID + }) +} + +func joinTags(tags []string) string { + if len(tags) == 0 { + return "" + } + cpy := append([]string(nil), tags...) + sort.Strings(cpy) + return strings.Join(cpy, " ") +} + +func priorityRank(priority string) int { + switch priority { + case "H": + return 3 + case "M": + return 2 + case "L": + return 1 + default: + return 0 + } +} + +func parseDueDate(value string) (time.Time, bool) { + if value == "" { + return time.Time{}, false + } + parsed, err := time.Parse(DateFormat, value) + if err != nil { + return time.Time{}, false + } + return parsed, true +} + +func isOverdue(task Task, now time.Time) bool { + due, ok := parseDueDate(task.Due) + return ok && now.After(due) +} diff --git a/internal/task/sort_test.go b/internal/task/sort_test.go index 056a389..e2b87cc 100644 --- a/internal/task/sort_test.go +++ b/internal/task/sort_test.go @@ -3,6 +3,7 @@ package task import ( "reflect" "testing" + "time" ) func TestSortTasks(t *testing.T) { @@ -46,3 +47,121 @@ func TestSortTasksStartedFirst(t *testing.T) { t.Fatalf("unexpected order: %v", ids) } } + +func TestSortTasksOverdueFirst(t *testing.T) { + tasks := []Task{ + {ID: 1}, + {ID: 2, Due: "20200101T000000Z"}, + {ID: 3}, + } + + SortTasks(tasks) + + var ids []int + for _, tsk := range tasks { + ids = append(ids, tsk.ID) + } + want := []int{2, 1, 3} + if !reflect.DeepEqual(ids, want) { + t.Fatalf("unexpected order: %v", ids) + } +} + +func TestJoinTags(t *testing.T) { + tests := []struct { + name string + tags []string + want string + }{ + {name: "empty", tags: nil, want: ""}, + {name: "single", tags: []string{"alpha"}, want: "alpha"}, + {name: "sorted copy", tags: []string{"bravo", "alpha"}, want: "alpha bravo"}, + {name: "duplicate values", tags: []string{"b", "a", "b"}, want: "a b b"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := joinTags(tt.tags); got != tt.want { + t.Fatalf("joinTags(%v) = %q, want %q", tt.tags, got, tt.want) + } + }) + } +} + +func TestPriorityRank(t *testing.T) { + tests := []struct { + priority string + want int + }{ + {priority: "H", want: 3}, + {priority: "M", want: 2}, + {priority: "L", want: 1}, + {priority: "", want: 0}, + {priority: "unexpected", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.priority, func(t *testing.T) { + if got := priorityRank(tt.priority); got != tt.want { + t.Fatalf("priorityRank(%q) = %d, want %d", tt.priority, got, tt.want) + } + }) + } +} + +func TestParseDueDate(t *testing.T) { + tests := []struct { + name string + value string + want bool + }{ + {name: "valid", value: "20240101T000000Z", want: true}, + {name: "empty", value: "", want: false}, + {name: "invalid", value: "not-a-date", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTime, gotOK := parseDueDate(tt.value) + if gotOK != tt.want { + t.Fatalf("parseDueDate(%q) ok = %v, want %v", tt.value, gotOK, tt.want) + } + if tt.want { + wantTime, err := time.Parse(DateFormat, tt.value) + if err != nil { + t.Fatalf("test setup error parsing %q: %v", tt.value, err) + } + if !gotTime.Equal(wantTime) { + t.Fatalf("parseDueDate(%q) time = %v, want %v", tt.value, gotTime, wantTime) + } + return + } + if !gotTime.IsZero() { + t.Fatalf("parseDueDate(%q) returned non-zero time: %v", tt.value, gotTime) + } + }) + } +} + +func TestIsOverdue(t *testing.T) { + now := time.Date(2024, time.January, 2, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + task Task + want bool + }{ + {name: "past due", task: Task{Due: "20240101T000000Z"}, want: true}, + {name: "future due", task: Task{Due: "20240103T000000Z"}, want: false}, + {name: "invalid due", task: Task{Due: "bad-date"}, want: false}, + {name: "empty due", task: Task{}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isOverdue(tt.task, now); got != tt.want { + t.Fatalf("isOverdue(%+v, %v) = %v, want %v", tt.task, now, got, tt.want) + } + }) + } +} diff --git a/internal/task/task.go b/internal/task/task.go index 51d1451..42fbb30 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -8,10 +8,8 @@ import ( "io" "os" "os/exec" - "sort" "strconv" "strings" - "time" "github.com/google/shlex" ) @@ -393,97 +391,3 @@ func Edit(id int) error { } return EditCmd(id).Run() } - -// SortTasks orders tasks by start status, priority, due date, tag names and id. -// Started tasks are always placed before non-started ones. Tasks without a due -// date are placed after tasks with a due date. Overdue tasks are placed at the -// very top regardless of other properties. -// -// The sort order is: -// 1. Overdue tasks (oldest due date first) -// 2. Started tasks (not completed) -// 3. High priority tasks -// 4. Tasks with earlier due dates -// 5. Tasks sorted alphabetically by tags -// 6. Tasks sorted by ID (oldest first) -func SortTasks(tasks []Task) { - // Helper to join tags in a consistent order for comparison - joinTags := func(tags []string) string { - if len(tags) == 0 { - return "" - } - cpy := append([]string(nil), tags...) - sort.Strings(cpy) - return strings.Join(cpy, " ") - } - - // Convert priority to numeric value for comparison (higher = more important) - priVal := func(p string) int { - switch p { - case "H": - return 3 - case "M": - return 2 - case "L": - return 1 - default: - return 0 - } - } - - // Parse due date string into time.Time - parseDue := func(s string) (time.Time, bool) { - if s == "" { - return time.Time{}, false - } - t, err := time.Parse(DateFormat, s) - if err != nil { - return time.Time{}, false - } - return t, true - } - - // Check if a task is overdue - overdue := func(t Task) bool { - du, ok := parseDue(t.Due) - return ok && time.Now().After(du) - } - - sort.Slice(tasks, func(i, j int) bool { - ti, tj := tasks[i], tasks[j] - - if oi, oj := overdue(ti), overdue(tj); oi != oj { - return oi - } - - startedI := ti.Start != "" && ti.Status != "completed" - startedJ := tj.Start != "" && tj.Status != "completed" - if startedI != startedJ { - return startedI - } - - pi, pj := priVal(ti.Priority), priVal(tj.Priority) - if pi != pj { - return pi > pj - } - - di, iok := parseDue(ti.Due) - dj, jok := parseDue(tj.Due) - if iok && !jok { - return true - } - if !iok && jok { - return false - } - if iok && jok && !di.Equal(dj) { - return di.Before(dj) - } - - tgI, tgJ := joinTags(ti.Tags), joinTags(tj.Tags) - if tgI != tgJ { - return tgI < tgJ - } - - return ti.ID < tj.ID - }) -} |
