summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 22:05:11 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 22:05:11 +0300
commit595075473a51b2709f1554f055df43a69369cddd (patch)
tree9a0fb966535af4aa23c177991d92b8f6c36e1eed
parentb6e10387a6a22f96e91542d487014b40e42c1dd0 (diff)
Task 9: extract SortTasks helpers
-rw-r--r--internal/task/sort.go99
-rw-r--r--internal/task/sort_test.go119
-rw-r--r--internal/task/task.go96
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
- })
-}