Skip to content

Commit effd089

Browse files
committed
feat(model): task struct, priority enum, due handling
Signed-off-by: Sanjay Santhanam <[email protected]>
1 parent 03e11f8 commit effd089

2 files changed

Lines changed: 297 additions & 0 deletions

File tree

internal/model/task.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Package model defines the core task data types used across tsk.
2+
package model
3+
4+
import (
5+
"fmt"
6+
"sort"
7+
"strings"
8+
"time"
9+
)
10+
11+
// Priority represents task urgency. Zero value is PriorityMedium.
12+
type Priority int
13+
14+
// Priority levels in ascending order of urgency.
15+
const (
16+
PriorityLow Priority = iota
17+
PriorityMedium
18+
PriorityHigh
19+
PriorityUrgent
20+
)
21+
22+
// DateLayout is the canonical date format used for due dates in storage and CLI input.
23+
const DateLayout = "2006-01-02"
24+
25+
// String returns the lowercase name of the priority.
26+
func (p Priority) String() string {
27+
switch p {
28+
case PriorityLow:
29+
return "low"
30+
case PriorityMedium:
31+
return "medium"
32+
case PriorityHigh:
33+
return "high"
34+
case PriorityUrgent:
35+
return "urgent"
36+
default:
37+
return "medium"
38+
}
39+
}
40+
41+
// Short returns a single-character priority marker.
42+
func (p Priority) Short() string {
43+
switch p {
44+
case PriorityLow:
45+
return "L"
46+
case PriorityMedium:
47+
return "M"
48+
case PriorityHigh:
49+
return "H"
50+
case PriorityUrgent:
51+
return "U"
52+
default:
53+
return "M"
54+
}
55+
}
56+
57+
// ParsePriority resolves a string (case-insensitive, short or long form) to a Priority.
58+
// Returns an error if the value is not recognized.
59+
func ParsePriority(s string) (Priority, error) {
60+
switch strings.ToLower(strings.TrimSpace(s)) {
61+
case "", "medium", "med", "m":
62+
return PriorityMedium, nil
63+
case "low", "l":
64+
return PriorityLow, nil
65+
case "high", "h":
66+
return PriorityHigh, nil
67+
case "urgent", "u", "critical":
68+
return PriorityUrgent, nil
69+
}
70+
return PriorityMedium, fmt.Errorf("unknown priority %q", s)
71+
}
72+
73+
// Task is a single todo item parsed from (or written to) a markdown store.
74+
type Task struct {
75+
ID int
76+
Title string
77+
Done bool
78+
Priority Priority
79+
Due *time.Time
80+
Tags []string
81+
Notes string
82+
Created time.Time
83+
Completed *time.Time
84+
}
85+
86+
// HasDue reports whether the task has a due date set.
87+
func (t *Task) HasDue() bool { return t.Due != nil }
88+
89+
// IsOverdue reports whether the task is undone and the due date is before today.
90+
func (t *Task) IsOverdue(now time.Time) bool {
91+
if t.Done || t.Due == nil {
92+
return false
93+
}
94+
return t.Due.Before(startOfDay(now))
95+
}
96+
97+
// IsDueToday reports whether the due date falls on now's calendar day.
98+
func (t *Task) IsDueToday(now time.Time) bool {
99+
if t.Due == nil {
100+
return false
101+
}
102+
return sameDay(*t.Due, now)
103+
}
104+
105+
// IsUpcoming reports whether the due date is strictly after today.
106+
func (t *Task) IsUpcoming(now time.Time) bool {
107+
if t.Due == nil {
108+
return false
109+
}
110+
return t.Due.After(endOfDay(now))
111+
}
112+
113+
// HasTag reports whether the task contains the given tag (case-insensitive).
114+
func (t *Task) HasTag(tag string) bool {
115+
tag = strings.ToLower(strings.TrimSpace(tag))
116+
for _, x := range t.Tags {
117+
if strings.EqualFold(x, tag) {
118+
return true
119+
}
120+
}
121+
return false
122+
}
123+
124+
// NormalizeTags trims, lowercases and de-duplicates tags in place.
125+
func (t *Task) NormalizeTags() {
126+
seen := make(map[string]struct{}, len(t.Tags))
127+
out := t.Tags[:0]
128+
for _, tag := range t.Tags {
129+
tag = strings.ToLower(strings.TrimSpace(tag))
130+
if tag == "" {
131+
continue
132+
}
133+
if _, ok := seen[tag]; ok {
134+
continue
135+
}
136+
seen[tag] = struct{}{}
137+
out = append(out, tag)
138+
}
139+
sort.Strings(out)
140+
t.Tags = out
141+
}
142+
143+
func startOfDay(t time.Time) time.Time {
144+
y, m, d := t.Date()
145+
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
146+
}
147+
148+
func endOfDay(t time.Time) time.Time {
149+
y, m, d := t.Date()
150+
return time.Date(y, m, d, 23, 59, 59, int(time.Second-time.Nanosecond), t.Location())
151+
}
152+
153+
func sameDay(a, b time.Time) bool {
154+
ay, am, ad := a.Date()
155+
by, bm, bd := b.Date()
156+
return ay == by && am == bm && ad == bd
157+
}
158+
159+
// SortBy sorts tasks in place according to the named strategy.
160+
// Recognized values: "priority", "due", "created", "id". Unknown values default to "id".
161+
func SortBy(tasks []Task, strategy string) {
162+
switch strings.ToLower(strategy) {
163+
case "priority":
164+
sort.SliceStable(tasks, func(i, j int) bool {
165+
if tasks[i].Priority != tasks[j].Priority {
166+
return tasks[i].Priority > tasks[j].Priority
167+
}
168+
return tasks[i].ID < tasks[j].ID
169+
})
170+
case "due":
171+
sort.SliceStable(tasks, func(i, j int) bool {
172+
ai, aj := tasks[i].Due, tasks[j].Due
173+
switch {
174+
case ai == nil && aj == nil:
175+
return tasks[i].ID < tasks[j].ID
176+
case ai == nil:
177+
return false
178+
case aj == nil:
179+
return true
180+
default:
181+
return ai.Before(*aj)
182+
}
183+
})
184+
case "created":
185+
sort.SliceStable(tasks, func(i, j int) bool {
186+
return tasks[i].Created.Before(tasks[j].Created)
187+
})
188+
default:
189+
sort.SliceStable(tasks, func(i, j int) bool {
190+
return tasks[i].ID < tasks[j].ID
191+
})
192+
}
193+
}

internal/model/task_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package model
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestParsePriority(t *testing.T) {
9+
tests := []struct {
10+
in string
11+
want Priority
12+
err bool
13+
}{
14+
{"low", PriorityLow, false},
15+
{"LOW", PriorityLow, false},
16+
{"medium", PriorityMedium, false},
17+
{"", PriorityMedium, false},
18+
{"m", PriorityMedium, false},
19+
{"high", PriorityHigh, false},
20+
{"urgent", PriorityUrgent, false},
21+
{"critical", PriorityUrgent, false},
22+
{"wat", PriorityMedium, true},
23+
}
24+
for _, tt := range tests {
25+
got, err := ParsePriority(tt.in)
26+
if (err != nil) != tt.err {
27+
t.Errorf("ParsePriority(%q) err=%v want err=%v", tt.in, err, tt.err)
28+
}
29+
if !tt.err && got != tt.want {
30+
t.Errorf("ParsePriority(%q) = %v want %v", tt.in, got, tt.want)
31+
}
32+
}
33+
}
34+
35+
func TestTaskDueBuckets(t *testing.T) {
36+
now := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
37+
yesterday := now.AddDate(0, 0, -1)
38+
tomorrow := now.AddDate(0, 0, 1)
39+
40+
overdue := Task{Due: &yesterday}
41+
today := Task{Due: &now}
42+
upcoming := Task{Due: &tomorrow}
43+
none := Task{}
44+
45+
if !overdue.IsOverdue(now) {
46+
t.Error("expected overdue")
47+
}
48+
if today.IsOverdue(now) {
49+
t.Error("today should not be overdue")
50+
}
51+
if !today.IsDueToday(now) {
52+
t.Error("expected today")
53+
}
54+
if !upcoming.IsUpcoming(now) {
55+
t.Error("expected upcoming")
56+
}
57+
if none.IsOverdue(now) || none.IsDueToday(now) || none.IsUpcoming(now) {
58+
t.Error("no-due task should not match any bucket")
59+
}
60+
61+
done := Task{Done: true, Due: &yesterday}
62+
if done.IsOverdue(now) {
63+
t.Error("done task cannot be overdue")
64+
}
65+
}
66+
67+
func TestNormalizeTags(t *testing.T) {
68+
task := Task{Tags: []string{"Home", "home", " WORK ", "", "errand"}}
69+
task.NormalizeTags()
70+
want := []string{"errand", "home", "work"}
71+
if len(task.Tags) != len(want) {
72+
t.Fatalf("got %v want %v", task.Tags, want)
73+
}
74+
for i, v := range want {
75+
if task.Tags[i] != v {
76+
t.Fatalf("got %v want %v", task.Tags, want)
77+
}
78+
}
79+
}
80+
81+
func TestSortBy(t *testing.T) {
82+
a := time.Date(2026, 4, 21, 0, 0, 0, 0, time.UTC)
83+
b := time.Date(2026, 4, 22, 0, 0, 0, 0, time.UTC)
84+
tasks := []Task{
85+
{ID: 1, Priority: PriorityLow, Due: &b},
86+
{ID: 2, Priority: PriorityUrgent, Due: nil},
87+
{ID: 3, Priority: PriorityHigh, Due: &a},
88+
}
89+
cp := append([]Task(nil), tasks...)
90+
SortBy(cp, "priority")
91+
if cp[0].ID != 2 || cp[1].ID != 3 || cp[2].ID != 1 {
92+
t.Errorf("priority sort wrong: %v", cp)
93+
}
94+
cp = append([]Task(nil), tasks...)
95+
SortBy(cp, "due")
96+
if cp[0].ID != 3 || cp[1].ID != 1 || cp[2].ID != 2 {
97+
t.Errorf("due sort wrong: %v", cp)
98+
}
99+
cp = append([]Task(nil), tasks...)
100+
SortBy(cp, "id")
101+
if cp[0].ID != 1 || cp[2].ID != 3 {
102+
t.Errorf("id sort wrong: %v", cp)
103+
}
104+
}

0 commit comments

Comments
 (0)