tangled
alpha
login
or
join now
desertthunder.dev
/
noteleaf
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐
charm
leaflet
readability
golang
29
fork
atom
overview
issues
2
pulls
pipelines
feat: add contexts to task domain
desertthunder.dev
5 months ago
c32e8191
bb1094aa
+381
-127
11 changed files
expand all
collapse all
unified
split
cmd
task_commands.go
internal
handlers
tasks.go
tasks_test.go
time_tracking_test.go
models
models.go
repo
repositories_test.go
task_repository.go
task_repository_test.go
store
sql
migrations
0005_add_context_to_tasks_down.sql
0005_add_context_to_tasks_up.sql
ui
task_list.go
+17
-9
cmd/task_commands.go
···
1
package main
2
3
import (
4
-
"fmt"
5
-
6
"github.com/spf13/cobra"
7
"github.com/stormlightlabs/noteleaf/internal/handlers"
8
)
···
16
RunE: func(c *cobra.Command, args []string) error {
17
priority, _ := c.Flags().GetString("priority")
18
project, _ := c.Flags().GetString("project")
0
19
due, _ := c.Flags().GetString("due")
20
tags, _ := c.Flags().GetStringSlice("tags")
21
22
defer h.Close()
23
-
return h.Create(c.Context(), args, priority, project, due, tags)
24
},
25
}
26
cmd.Flags().StringP("priority", "p", "", "Set task priority")
27
cmd.Flags().String("project", "", "Set task project")
0
28
cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)")
29
cmd.Flags().StringSliceP("tags", "t", []string{}, "Add tags to task")
30
···
47
status, _ := c.Flags().GetString("status")
48
priority, _ := c.Flags().GetString("priority")
49
project, _ := c.Flags().GetString("project")
0
50
51
defer h.Close()
52
-
return h.List(c.Context(), static, showAll, status, priority, project)
53
},
54
}
55
cmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)")
···
58
cmd.Flags().String("status", "", "Filter by status")
59
cmd.Flags().String("priority", "", "Filter by priority")
60
cmd.Flags().String("project", "", "Filter by project")
0
61
62
return cmd
63
}
···
94
status, _ := cmd.Flags().GetString("status")
95
priority, _ := cmd.Flags().GetString("priority")
96
project, _ := cmd.Flags().GetString("project")
0
97
due, _ := cmd.Flags().GetString("due")
98
addTags, _ := cmd.Flags().GetStringSlice("add-tag")
99
removeTags, _ := cmd.Flags().GetStringSlice("remove-tag")
100
101
defer handler.Close()
102
-
return handler.Update(cmd.Context(), taskID, description, status, priority, project, due, addTags, removeTags)
103
},
104
}
105
updateCmd.Flags().String("description", "", "Update task description")
106
updateCmd.Flags().String("status", "", "Update task status")
107
updateCmd.Flags().StringP("priority", "p", "", "Update task priority")
108
updateCmd.Flags().String("project", "", "Update task project")
0
109
updateCmd.Flags().StringP("due", "d", "", "Update due date (YYYY-MM-DD)")
110
updateCmd.Flags().StringSlice("add-tag", []string{}, "Add tags to task")
111
updateCmd.Flags().StringSlice("remove-tag", []string{}, "Remove tags from task")
···
223
}
224
}
225
226
-
func taskContextsCmd(*handlers.TaskHandler) *cobra.Command {
227
-
return &cobra.Command{
228
Use: "contexts",
229
Short: "List contexts (locations)",
230
Aliases: []string{"loc", "ctx", "locations"},
231
RunE: func(c *cobra.Command, args []string) error {
232
-
fmt.Println("Listing task contexts...")
233
-
return nil
0
0
234
},
235
}
0
0
236
}
237
238
func taskCompleteCmd(h *handlers.TaskHandler) *cobra.Command {
···
1
package main
2
3
import (
0
0
4
"github.com/spf13/cobra"
5
"github.com/stormlightlabs/noteleaf/internal/handlers"
6
)
···
14
RunE: func(c *cobra.Command, args []string) error {
15
priority, _ := c.Flags().GetString("priority")
16
project, _ := c.Flags().GetString("project")
17
+
context, _ := c.Flags().GetString("context")
18
due, _ := c.Flags().GetString("due")
19
tags, _ := c.Flags().GetStringSlice("tags")
20
21
defer h.Close()
22
+
return h.Create(c.Context(), args, priority, project, context, due, tags)
23
},
24
}
25
cmd.Flags().StringP("priority", "p", "", "Set task priority")
26
cmd.Flags().String("project", "", "Set task project")
27
+
cmd.Flags().StringP("context", "c", "", "Set task context")
28
cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)")
29
cmd.Flags().StringSliceP("tags", "t", []string{}, "Add tags to task")
30
···
47
status, _ := c.Flags().GetString("status")
48
priority, _ := c.Flags().GetString("priority")
49
project, _ := c.Flags().GetString("project")
50
+
context, _ := c.Flags().GetString("context")
51
52
defer h.Close()
53
+
return h.List(c.Context(), static, showAll, status, priority, project, context)
54
},
55
}
56
cmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)")
···
59
cmd.Flags().String("status", "", "Filter by status")
60
cmd.Flags().String("priority", "", "Filter by priority")
61
cmd.Flags().String("project", "", "Filter by project")
62
+
cmd.Flags().String("context", "", "Filter by context")
63
64
return cmd
65
}
···
96
status, _ := cmd.Flags().GetString("status")
97
priority, _ := cmd.Flags().GetString("priority")
98
project, _ := cmd.Flags().GetString("project")
99
+
context, _ := cmd.Flags().GetString("context")
100
due, _ := cmd.Flags().GetString("due")
101
addTags, _ := cmd.Flags().GetStringSlice("add-tag")
102
removeTags, _ := cmd.Flags().GetStringSlice("remove-tag")
103
104
defer handler.Close()
105
+
return handler.Update(cmd.Context(), taskID, description, status, priority, project, context, due, addTags, removeTags)
106
},
107
}
108
updateCmd.Flags().String("description", "", "Update task description")
109
updateCmd.Flags().String("status", "", "Update task status")
110
updateCmd.Flags().StringP("priority", "p", "", "Update task priority")
111
updateCmd.Flags().String("project", "", "Update task project")
112
+
updateCmd.Flags().StringP("context", "c", "", "Update task context")
113
updateCmd.Flags().StringP("due", "d", "", "Update due date (YYYY-MM-DD)")
114
updateCmd.Flags().StringSlice("add-tag", []string{}, "Add tags to task")
115
updateCmd.Flags().StringSlice("remove-tag", []string{}, "Remove tags from task")
···
227
}
228
}
229
230
+
func taskContextsCmd(h *handlers.TaskHandler) *cobra.Command {
231
+
cmd := &cobra.Command{
232
Use: "contexts",
233
Short: "List contexts (locations)",
234
Aliases: []string{"loc", "ctx", "locations"},
235
RunE: func(c *cobra.Command, args []string) error {
236
+
static, _ := c.Flags().GetBool("static")
237
+
238
+
defer h.Close()
239
+
return h.ListContexts(c.Context(), static)
240
},
241
}
242
+
cmd.Flags().Bool("static", false, "Use static text output instead of interactive")
243
+
return cmd
244
}
245
246
func taskCompleteCmd(h *handlers.TaskHandler) *cobra.Command {
+71
-8
internal/handlers/tasks.go
···
52
}
53
54
// Create creates a new task
55
-
func (h *TaskHandler) Create(ctx context.Context, desc []string, priority, project, due string, tags []string) error {
56
if len(desc) < 1 {
57
return fmt.Errorf("task description required")
58
}
···
65
Status: "pending",
66
Priority: priority,
67
Project: project,
0
68
Tags: tags,
69
}
70
···
88
}
89
if project != "" {
90
fmt.Printf("Project: %s\n", project)
0
0
0
91
}
92
if len(tags) > 0 {
93
fmt.Printf("Tags: %s\n", strings.Join(tags, ", "))
···
100
}
101
102
// List lists all tasks with optional filtering
103
-
func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project string) error {
104
if static {
105
-
return h.listTasksStatic(ctx, showAll, status, priority, project)
106
}
107
108
-
return h.listTasksInteractive(ctx, showAll, status, priority, project)
109
}
110
111
-
func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project string) error {
112
opts := repo.TaskListOptions{
113
Status: status,
114
Priority: priority,
115
Project: project,
0
116
}
117
118
if !showAll && opts.Status == "" {
···
137
return nil
138
}
139
140
-
func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project string) error {
141
taskList := ui.NewTaskList(h.repos.Tasks, ui.TaskListOptions{
142
ShowAll: showAll,
143
Status: status,
144
Priority: priority,
145
Project: project,
0
146
Static: false,
147
})
148
···
150
}
151
152
// Update updates a task using parsed flag values
153
-
func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, priority, project, due string, addTags, removeTags []string) error {
154
var task *models.Task
155
var err error
156
···
175
}
176
if project != "" {
177
task.Project = project
0
0
0
178
}
179
if due != "" {
180
if dueTime, err := time.Parse("2006-01-02", due); err == nil {
···
556
return h.listTagsInteractive(ctx)
557
}
558
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
559
func (h *TaskHandler) listTagsStatic(ctx context.Context) error {
560
tasks, err := h.repos.Tasks.List(ctx, repo.TaskListOptions{})
561
if err != nil {
···
609
fmt.Printf(" +%s", task.Project)
610
}
611
0
0
0
0
612
if len(task.Tags) > 0 {
613
-
fmt.Printf(" @%s", strings.Join(task.Tags, " @"))
614
}
615
616
if task.Due != nil {
···
632
633
if task.Project != "" {
634
fmt.Printf("Project: %s\n", task.Project)
0
0
0
0
635
}
636
637
if len(task.Tags) > 0 {
···
52
}
53
54
// Create creates a new task
55
+
func (h *TaskHandler) Create(ctx context.Context, desc []string, priority, project, context, due string, tags []string) error {
56
if len(desc) < 1 {
57
return fmt.Errorf("task description required")
58
}
···
65
Status: "pending",
66
Priority: priority,
67
Project: project,
68
+
Context: context,
69
Tags: tags,
70
}
71
···
89
}
90
if project != "" {
91
fmt.Printf("Project: %s\n", project)
92
+
}
93
+
if context != "" {
94
+
fmt.Printf("Context: %s\n", context)
95
}
96
if len(tags) > 0 {
97
fmt.Printf("Tags: %s\n", strings.Join(tags, ", "))
···
104
}
105
106
// List lists all tasks with optional filtering
107
+
func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project, context string) error {
108
if static {
109
+
return h.listTasksStatic(ctx, showAll, status, priority, project, context)
110
}
111
112
+
return h.listTasksInteractive(ctx, showAll, status, priority, project, context)
113
}
114
115
+
func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project, context string) error {
116
opts := repo.TaskListOptions{
117
Status: status,
118
Priority: priority,
119
Project: project,
120
+
Context: context,
121
}
122
123
if !showAll && opts.Status == "" {
···
142
return nil
143
}
144
145
+
func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project, context string) error {
146
taskList := ui.NewTaskList(h.repos.Tasks, ui.TaskListOptions{
147
ShowAll: showAll,
148
Status: status,
149
Priority: priority,
150
Project: project,
151
+
Context: context,
152
Static: false,
153
})
154
···
156
}
157
158
// Update updates a task using parsed flag values
159
+
func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, priority, project, context, due string, addTags, removeTags []string) error {
160
var task *models.Task
161
var err error
162
···
181
}
182
if project != "" {
183
task.Project = project
184
+
}
185
+
if context != "" {
186
+
task.Context = context
187
}
188
if due != "" {
189
if dueTime, err := time.Parse("2006-01-02", due); err == nil {
···
565
return h.listTagsInteractive(ctx)
566
}
567
568
+
// ListContexts lists all contexts with their task counts
569
+
func (h *TaskHandler) ListContexts(ctx context.Context, static bool) error {
570
+
if static {
571
+
return h.listContextsStatic(ctx)
572
+
}
573
+
return h.listContextsInteractive(ctx)
574
+
}
575
+
576
+
func (h *TaskHandler) listContextsStatic(ctx context.Context) error {
577
+
tasks, err := h.repos.Tasks.List(ctx, repo.TaskListOptions{})
578
+
if err != nil {
579
+
return fmt.Errorf("failed to list tasks for contexts: %w", err)
580
+
}
581
+
582
+
contextCounts := make(map[string]int)
583
+
for _, task := range tasks {
584
+
if task.Context != "" {
585
+
contextCounts[task.Context]++
586
+
}
587
+
}
588
+
589
+
if len(contextCounts) == 0 {
590
+
fmt.Printf("No contexts found\n")
591
+
return nil
592
+
}
593
+
594
+
contexts := make([]string, 0, len(contextCounts))
595
+
for context := range contextCounts {
596
+
contexts = append(contexts, context)
597
+
}
598
+
slices.Sort(contexts)
599
+
600
+
fmt.Printf("Found %d context(s):\n\n", len(contexts))
601
+
for _, context := range contexts {
602
+
count := contextCounts[context]
603
+
fmt.Printf("%s (%d task%s)\n", context, count, pluralize(count))
604
+
}
605
+
606
+
return nil
607
+
}
608
+
609
+
func (h *TaskHandler) listContextsInteractive(ctx context.Context) error {
610
+
fmt.Println("Interactive context listing not implemented yet - using static mode")
611
+
return h.listContextsStatic(ctx)
612
+
}
613
+
614
func (h *TaskHandler) listTagsStatic(ctx context.Context) error {
615
tasks, err := h.repos.Tasks.List(ctx, repo.TaskListOptions{})
616
if err != nil {
···
664
fmt.Printf(" +%s", task.Project)
665
}
666
667
+
if task.Context != "" {
668
+
fmt.Printf(" @%s", task.Context)
669
+
}
670
+
671
if len(task.Tags) > 0 {
672
+
fmt.Printf(" #%s", strings.Join(task.Tags, " #"))
673
}
674
675
if task.Due != nil {
···
691
692
if task.Project != "" {
693
fmt.Printf("Project: %s\n", task.Project)
694
+
}
695
+
696
+
if task.Context != "" {
697
+
fmt.Printf("Context: %s\n", task.Context)
698
}
699
700
if len(task.Tags) > 0 {
+16
-16
internal/handlers/tasks_test.go
···
103
ctx := context.Background()
104
args := []string{"Buy groceries", "and", "cook dinner"}
105
106
-
err := handler.Create(ctx, args, "", "", "", []string{})
107
if err != nil {
108
t.Errorf("CreateTask failed: %v", err)
109
}
···
136
ctx := context.Background()
137
args := []string{}
138
139
-
err := handler.Create(ctx, args, "", "", "", []string{})
140
if err == nil {
141
t.Error("Expected error for empty description")
142
}
···
154
due := "2024-12-31"
155
tags := []string{"urgent", "work"}
156
157
-
err := handler.Create(ctx, args, priority, project, due, tags)
158
if err != nil {
159
t.Errorf("CreateTask with flags failed: %v", err)
160
}
···
210
args := []string{"Task", "with", "invalid", "date"}
211
invalidDue := "invalid-date"
212
213
-
err := handler.Create(ctx, args, "", "", invalidDue, []string{})
214
if err == nil {
215
t.Error("Expected error for invalid due date format")
216
}
···
256
}
257
258
t.Run("lists pending tasks by default (static mode)", func(t *testing.T) {
259
-
err := handler.List(ctx, true, false, "", "", "")
260
if err != nil {
261
t.Errorf("ListTasks failed: %v", err)
262
}
263
})
264
265
t.Run("filters by status (static mode)", func(t *testing.T) {
266
-
err := handler.List(ctx, true, false, "completed", "", "")
267
if err != nil {
268
t.Errorf("ListTasks with status filter failed: %v", err)
269
}
270
})
271
272
t.Run("filters by priority (static mode)", func(t *testing.T) {
273
-
err := handler.List(ctx, true, false, "", "A", "")
274
if err != nil {
275
t.Errorf("ListTasks with priority filter failed: %v", err)
276
}
277
})
278
279
t.Run("filters by project (static mode)", func(t *testing.T) {
280
-
err := handler.List(ctx, true, false, "", "", "work")
281
if err != nil {
282
t.Errorf("ListTasks with project filter failed: %v", err)
283
}
284
})
285
286
t.Run("show all tasks (static mode)", func(t *testing.T) {
287
-
err := handler.List(ctx, true, true, "", "", "")
288
if err != nil {
289
t.Errorf("ListTasks with show all failed: %v", err)
290
}
···
316
t.Run("updates task by ID", func(t *testing.T) {
317
taskID := strconv.FormatInt(id, 10)
318
319
-
err := handler.Update(ctx, taskID, "Updated description", "", "", "", "", []string{}, []string{})
320
if err != nil {
321
t.Errorf("UpdateTask failed: %v", err)
322
}
···
334
t.Run("updates task by UUID", func(t *testing.T) {
335
taskID := task.UUID
336
337
-
err := handler.Update(ctx, taskID, "", "completed", "", "", "", []string{}, []string{})
338
if err != nil {
339
t.Errorf("UpdateTask by UUID failed: %v", err)
340
}
···
352
t.Run("updates multiple fields", func(t *testing.T) {
353
taskID := strconv.FormatInt(id, 10)
354
355
-
err := handler.Update(ctx, taskID, "Multiple updates", "", "B", "test", "2024-12-31", []string{}, []string{})
356
if err != nil {
357
t.Errorf("UpdateTask with multiple fields failed: %v", err)
358
}
···
379
t.Run("adds and removes tags", func(t *testing.T) {
380
taskID := strconv.FormatInt(id, 10)
381
382
-
err := handler.Update(ctx, taskID, "", "", "", "", "", []string{"work", "urgent"}, []string{})
383
if err != nil {
384
t.Errorf("UpdateTask with add tags failed: %v", err)
385
}
···
395
396
taskID = strconv.FormatInt(id, 10)
397
398
-
err = handler.Update(ctx, taskID, "", "", "", "", "", []string{}, []string{"urgent"})
399
if err != nil {
400
t.Errorf("UpdateTask with remove tag failed: %v", err)
401
}
···
415
})
416
417
t.Run("fails with missing task ID", func(t *testing.T) {
418
-
err := handler.Update(ctx, "", "", "", "", "", "", []string{}, []string{})
419
if err == nil {
420
t.Error("Expected error for missing task ID")
421
}
···
428
t.Run("fails with invalid task ID", func(t *testing.T) {
429
taskID := "99999"
430
431
-
err := handler.Update(ctx, taskID, "test", "", "", "", "", []string{}, []string{})
432
if err == nil {
433
t.Error("Expected error for invalid task ID")
434
}
···
103
ctx := context.Background()
104
args := []string{"Buy groceries", "and", "cook dinner"}
105
106
+
err := handler.Create(ctx, args, "", "", "", "", []string{})
107
if err != nil {
108
t.Errorf("CreateTask failed: %v", err)
109
}
···
136
ctx := context.Background()
137
args := []string{}
138
139
+
err := handler.Create(ctx, args, "", "", "", "", []string{})
140
if err == nil {
141
t.Error("Expected error for empty description")
142
}
···
154
due := "2024-12-31"
155
tags := []string{"urgent", "work"}
156
157
+
err := handler.Create(ctx, args, priority, project, "test-context", due, tags)
158
if err != nil {
159
t.Errorf("CreateTask with flags failed: %v", err)
160
}
···
210
args := []string{"Task", "with", "invalid", "date"}
211
invalidDue := "invalid-date"
212
213
+
err := handler.Create(ctx, args, "", "", "", invalidDue, []string{})
214
if err == nil {
215
t.Error("Expected error for invalid due date format")
216
}
···
256
}
257
258
t.Run("lists pending tasks by default (static mode)", func(t *testing.T) {
259
+
err := handler.List(ctx, true, false, "", "", "", "")
260
if err != nil {
261
t.Errorf("ListTasks failed: %v", err)
262
}
263
})
264
265
t.Run("filters by status (static mode)", func(t *testing.T) {
266
+
err := handler.List(ctx, true, false, "completed", "", "", "")
267
if err != nil {
268
t.Errorf("ListTasks with status filter failed: %v", err)
269
}
270
})
271
272
t.Run("filters by priority (static mode)", func(t *testing.T) {
273
+
err := handler.List(ctx, true, false, "", "A", "", "")
274
if err != nil {
275
t.Errorf("ListTasks with priority filter failed: %v", err)
276
}
277
})
278
279
t.Run("filters by project (static mode)", func(t *testing.T) {
280
+
err := handler.List(ctx, true, false, "", "", "work", "")
281
if err != nil {
282
t.Errorf("ListTasks with project filter failed: %v", err)
283
}
284
})
285
286
t.Run("show all tasks (static mode)", func(t *testing.T) {
287
+
err := handler.List(ctx, true, true, "", "", "", "")
288
if err != nil {
289
t.Errorf("ListTasks with show all failed: %v", err)
290
}
···
316
t.Run("updates task by ID", func(t *testing.T) {
317
taskID := strconv.FormatInt(id, 10)
318
319
+
err := handler.Update(ctx, taskID, "Updated description", "", "", "", "", "", []string{}, []string{})
320
if err != nil {
321
t.Errorf("UpdateTask failed: %v", err)
322
}
···
334
t.Run("updates task by UUID", func(t *testing.T) {
335
taskID := task.UUID
336
337
+
err := handler.Update(ctx, taskID, "", "completed", "", "", "", "", []string{}, []string{})
338
if err != nil {
339
t.Errorf("UpdateTask by UUID failed: %v", err)
340
}
···
352
t.Run("updates multiple fields", func(t *testing.T) {
353
taskID := strconv.FormatInt(id, 10)
354
355
+
err := handler.Update(ctx, taskID, "Multiple updates", "", "B", "test", "office", "2024-12-31", []string{}, []string{})
356
if err != nil {
357
t.Errorf("UpdateTask with multiple fields failed: %v", err)
358
}
···
379
t.Run("adds and removes tags", func(t *testing.T) {
380
taskID := strconv.FormatInt(id, 10)
381
382
+
err := handler.Update(ctx, taskID, "", "", "", "", "", "", []string{"work", "urgent"}, []string{})
383
if err != nil {
384
t.Errorf("UpdateTask with add tags failed: %v", err)
385
}
···
395
396
taskID = strconv.FormatInt(id, 10)
397
398
+
err = handler.Update(ctx, taskID, "", "", "", "", "", "", []string{}, []string{"urgent"})
399
if err != nil {
400
t.Errorf("UpdateTask with remove tag failed: %v", err)
401
}
···
415
})
416
417
t.Run("fails with missing task ID", func(t *testing.T) {
418
+
err := handler.Update(ctx, "", "", "", "", "", "", "", []string{}, []string{})
419
if err == nil {
420
t.Error("Expected error for missing task ID")
421
}
···
428
t.Run("fails with invalid task ID", func(t *testing.T) {
429
taskID := "99999"
430
431
+
err := handler.Update(ctx, taskID, "test", "", "", "", "", "", []string{}, []string{})
432
if err == nil {
433
t.Error("Expected error for invalid task ID")
434
}
+82
-82
internal/handlers/time_tracking_test.go
···
261
}
262
})
263
})
264
-
}
265
266
-
func TestFormatDuration(t *testing.T) {
267
-
tests := []struct {
268
-
duration time.Duration
269
-
expected string
270
-
}{
271
-
{30 * time.Second, "30s"},
272
-
{90 * time.Second, "2m"},
273
-
{30 * time.Minute, "30m"},
274
-
{90 * time.Minute, "1.5h"},
275
-
{2 * time.Hour, "2.0h"},
276
-
{25 * time.Hour, "1d 1.0h"},
277
-
{48 * time.Hour, "2d"},
278
-
{72 * time.Hour, "3d"},
279
-
}
280
281
-
for _, test := range tests {
282
-
result := formatDuration(test.duration)
283
-
if result != test.expected {
284
-
t.Errorf("formatDuration(%v) = %q, expected %q", test.duration, result, test.expected)
0
285
}
286
-
}
287
-
}
288
289
-
func TestTimeEntryMethods(t *testing.T) {
290
-
now := time.Now()
291
292
-
t.Run("IsActive returns true for entry without end time", func(t *testing.T) {
293
-
entry := &models.TimeEntry{
294
-
StartTime: now,
295
-
EndTime: nil,
296
-
}
297
298
-
if !entry.IsActive() {
299
-
t.Error("Expected entry to be active")
300
-
}
301
-
})
302
303
-
t.Run("IsActive returns false for entry with end time", func(t *testing.T) {
304
-
endTime := now.Add(time.Hour)
305
-
entry := &models.TimeEntry{
306
-
StartTime: now,
307
-
EndTime: &endTime,
308
-
}
309
310
-
if entry.IsActive() {
311
-
t.Error("Expected entry to not be active")
312
-
}
313
-
})
314
315
-
t.Run("Stop sets end time and calculates duration", func(t *testing.T) {
316
-
entry := &models.TimeEntry{
317
-
StartTime: now.Add(-time.Second), // Start 1 second ago
318
-
EndTime: nil,
319
-
}
320
321
-
entry.Stop()
322
323
-
if entry.EndTime == nil {
324
-
t.Error("Expected EndTime to be set after stopping")
325
-
}
326
-
if entry.DurationSeconds <= 0 {
327
-
t.Error("Expected duration to be calculated and greater than 0")
328
-
}
329
-
if entry.IsActive() {
330
-
t.Error("Expected entry to not be active after stopping")
331
-
}
332
-
})
333
334
-
t.Run("GetDuration returns calculated duration for completed entry", func(t *testing.T) {
335
-
start := now
336
-
end := now.Add(2 * time.Hour)
337
-
entry := &models.TimeEntry{
338
-
StartTime: start,
339
-
EndTime: &end,
340
-
DurationSeconds: int64((2 * time.Hour).Seconds()),
341
-
}
342
343
-
duration := entry.GetDuration()
344
-
expected := 2 * time.Hour
345
346
-
if duration != expected {
347
-
t.Errorf("Expected duration %v, got %v", expected, duration)
348
-
}
349
-
})
350
351
-
t.Run("GetDuration returns live duration for active entry", func(t *testing.T) {
352
-
start := time.Now().Add(-time.Minute)
353
-
entry := &models.TimeEntry{
354
-
StartTime: start,
355
-
EndTime: nil,
356
-
}
357
358
-
duration := entry.GetDuration()
359
360
-
if duration < 59*time.Second || duration > 61*time.Second {
361
-
t.Errorf("Expected duration around 1 minute, got %v", duration)
362
-
}
0
363
})
364
}
···
261
}
262
})
263
})
0
264
265
+
t.Run("TestFormatDuration", func(t *testing.T) {
266
+
tests := []struct {
267
+
duration time.Duration
268
+
expected string
269
+
}{
270
+
{30 * time.Second, "30s"},
271
+
{90 * time.Second, "2m"},
272
+
{30 * time.Minute, "30m"},
273
+
{90 * time.Minute, "1.5h"},
274
+
{2 * time.Hour, "2.0h"},
275
+
{25 * time.Hour, "1d 1.0h"},
276
+
{48 * time.Hour, "2d"},
277
+
{72 * time.Hour, "3d"},
278
+
}
279
280
+
for _, test := range tests {
281
+
result := formatDuration(test.duration)
282
+
if result != test.expected {
283
+
t.Errorf("formatDuration(%v) = %q, expected %q", test.duration, result, test.expected)
284
+
}
285
}
286
+
})
0
287
288
+
t.Run("TestTimeEntryMethods", func(t *testing.T) {
289
+
now := time.Now()
290
291
+
t.Run("IsActive returns true for entry without end time", func(t *testing.T) {
292
+
entry := &models.TimeEntry{
293
+
StartTime: now,
294
+
EndTime: nil,
295
+
}
296
297
+
if !entry.IsActive() {
298
+
t.Error("Expected entry to be active")
299
+
}
300
+
})
301
302
+
t.Run("IsActive returns false for entry with end time", func(t *testing.T) {
303
+
endTime := now.Add(time.Hour)
304
+
entry := &models.TimeEntry{
305
+
StartTime: now,
306
+
EndTime: &endTime,
307
+
}
308
309
+
if entry.IsActive() {
310
+
t.Error("Expected entry to not be active")
311
+
}
312
+
})
313
314
+
t.Run("Stop sets end time and calculates duration", func(t *testing.T) {
315
+
entry := &models.TimeEntry{
316
+
StartTime: now.Add(-time.Second), // Start 1 second ago
317
+
EndTime: nil,
318
+
}
319
320
+
entry.Stop()
321
322
+
if entry.EndTime == nil {
323
+
t.Error("Expected EndTime to be set after stopping")
324
+
}
325
+
if entry.DurationSeconds <= 0 {
326
+
t.Error("Expected duration to be calculated and greater than 0")
327
+
}
328
+
if entry.IsActive() {
329
+
t.Error("Expected entry to not be active after stopping")
330
+
}
331
+
})
332
333
+
t.Run("GetDuration returns calculated duration for completed entry", func(t *testing.T) {
334
+
start := now
335
+
end := now.Add(2 * time.Hour)
336
+
entry := &models.TimeEntry{
337
+
StartTime: start,
338
+
EndTime: &end,
339
+
DurationSeconds: int64((2 * time.Hour).Seconds()),
340
+
}
341
342
+
duration := entry.GetDuration()
343
+
expected := 2 * time.Hour
344
345
+
if duration != expected {
346
+
t.Errorf("Expected duration %v, got %v", expected, duration)
347
+
}
348
+
})
349
350
+
t.Run("GetDuration returns live duration for active entry", func(t *testing.T) {
351
+
start := time.Now().Add(-time.Minute)
352
+
entry := &models.TimeEntry{
353
+
StartTime: start,
354
+
EndTime: nil,
355
+
}
356
357
+
duration := entry.GetDuration()
358
359
+
if duration < 59*time.Second || duration > 61*time.Second {
360
+
t.Errorf("Expected duration around 1 minute, got %v", duration)
361
+
}
362
+
})
363
})
364
}
+1
internal/models/models.go
···
63
// A-Z or empty
64
Priority string `json:"priority,omitempty"`
65
Project string `json:"project,omitempty"`
0
66
Tags []string `json:"tags,omitempty"`
67
Due *time.Time `json:"due,omitempty"`
68
Entry time.Time `json:"entry"`
···
63
// A-Z or empty
64
Priority string `json:"priority,omitempty"`
65
Project string `json:"project,omitempty"`
66
+
Context string `json:"context,omitempty"`
67
Tags []string `json:"tags,omitempty"`
68
Due *time.Time `json:"due,omitempty"`
69
Entry time.Time `json:"entry"`
+1
internal/repo/repositories_test.go
···
29
status TEXT DEFAULT 'pending',
30
priority TEXT,
31
project TEXT,
0
32
tags TEXT,
33
due DATETIME,
34
entry DATETIME DEFAULT CURRENT_TIMESTAMP,
···
29
status TEXT DEFAULT 'pending',
30
priority TEXT,
31
project TEXT,
32
+
context TEXT,
33
tags TEXT,
34
due DATETIME,
35
entry DATETIME DEFAULT CURRENT_TIMESTAMP,
+58
-12
internal/repo/task_repository.go
···
15
Status string
16
Priority string
17
Project string
0
18
DueAfter time.Time
19
DueBefore time.Time
20
Search string
···
32
33
// TagSummary represents a tag with its task count
34
type TagSummary struct {
0
0
0
0
0
0
35
Name string `json:"name"`
36
TaskCount int `json:"task_count"`
37
}
···
63
}
64
65
query := `
66
-
INSERT INTO tasks (uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations)
67
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
68
69
result, err := r.db.ExecContext(ctx, query,
70
-
task.UUID, task.Description, task.Status, task.Priority, task.Project,
71
tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations)
72
if err != nil {
73
return 0, fmt.Errorf("failed to insert task: %w", err)
···
85
// Get retrieves a task by ID
86
func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error) {
87
query := `
88
-
SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations
89
FROM tasks WHERE id = ?`
90
91
task := &models.Task{}
92
var tags, annotations sql.NullString
93
94
err := r.db.QueryRowContext(ctx, query, id).Scan(
95
-
&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project,
96
&tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations)
97
if err != nil {
98
return nil, fmt.Errorf("failed to get task: %w", err)
···
128
}
129
130
query := `
131
-
UPDATE tasks SET uuid = ?, description = ?, status = ?, priority = ?, project = ?,
132
tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?
133
WHERE id = ?`
134
135
_, err = r.db.ExecContext(ctx, query,
136
-
task.UUID, task.Description, task.Status, task.Priority, task.Project,
137
tags, task.Due, task.Modified, task.End, task.Start, annotations, task.ID)
138
if err != nil {
139
return fmt.Errorf("failed to update task: %w", err)
···
176
}
177
178
func (r *TaskRepository) buildListQuery(opts TaskListOptions) string {
179
-
query := "SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations FROM tasks"
180
181
var conditions []string
182
···
189
if opts.Project != "" {
190
conditions = append(conditions, "project = ?")
191
}
0
0
0
192
if !opts.DueAfter.IsZero() {
193
conditions = append(conditions, "due >= ?")
194
}
···
200
searchConditions := []string{
201
"description LIKE ?",
202
"project LIKE ?",
0
203
"tags LIKE ?",
204
}
205
conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
···
241
if opts.Project != "" {
242
args = append(args, opts.Project)
243
}
0
0
0
244
if !opts.DueAfter.IsZero() {
245
args = append(args, opts.DueAfter)
246
}
···
250
251
if opts.Search != "" {
252
searchPattern := "%" + opts.Search + "%"
253
-
args = append(args, searchPattern, searchPattern, searchPattern)
254
}
255
256
return args
···
260
var tags, annotations sql.NullString
261
262
if err := rows.Scan(&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority,
263
-
&task.Project, &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil {
264
return fmt.Errorf("failed to scan task row: %w", err)
265
}
266
···
381
return r.List(ctx, TaskListOptions{Project: project})
382
}
383
0
0
0
0
0
384
// GetProjects retrieves all unique project names with their task counts
385
func (r *TaskRepository) GetProjects(ctx context.Context) ([]ProjectSummary, error) {
386
query := `
···
435
return tags, rows.Err()
436
}
437
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
438
// GetTasksByTag retrieves all tasks with a specific tag
439
func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) {
440
query := `
441
-
SELECT tasks.id, tasks.uuid, tasks.description, tasks.status, tasks.priority, tasks.project, tasks.tags, tasks.due, tasks.entry, tasks.modified, tasks.end, tasks.start, tasks.annotations
442
FROM tasks, json_each(tasks.tags)
443
WHERE tasks.tags != '' AND tasks.tags IS NOT NULL AND json_each.value = ?
444
ORDER BY tasks.modified DESC`
···
492
func (r *TaskRepository) GetByPriority(ctx context.Context, priority string) ([]*models.Task, error) {
493
if priority == "" {
494
query := `
495
-
SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations
496
FROM tasks
497
WHERE priority = '' OR priority IS NULL
498
ORDER BY modified DESC`
···
15
Status string
16
Priority string
17
Project string
18
+
Context string
19
DueAfter time.Time
20
DueBefore time.Time
21
Search string
···
33
34
// TagSummary represents a tag with its task count
35
type TagSummary struct {
36
+
Name string `json:"name"`
37
+
TaskCount int `json:"task_count"`
38
+
}
39
+
40
+
// ContextSummary represents a context with its task count
41
+
type ContextSummary struct {
42
Name string `json:"name"`
43
TaskCount int `json:"task_count"`
44
}
···
70
}
71
72
query := `
73
+
INSERT INTO tasks (uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations)
74
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
75
76
result, err := r.db.ExecContext(ctx, query,
77
+
task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context,
78
tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations)
79
if err != nil {
80
return 0, fmt.Errorf("failed to insert task: %w", err)
···
92
// Get retrieves a task by ID
93
func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error) {
94
query := `
95
+
SELECT id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations
96
FROM tasks WHERE id = ?`
97
98
task := &models.Task{}
99
var tags, annotations sql.NullString
100
101
err := r.db.QueryRowContext(ctx, query, id).Scan(
102
+
&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project, &task.Context,
103
&tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations)
104
if err != nil {
105
return nil, fmt.Errorf("failed to get task: %w", err)
···
135
}
136
137
query := `
138
+
UPDATE tasks SET uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?,
139
tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?
140
WHERE id = ?`
141
142
_, err = r.db.ExecContext(ctx, query,
143
+
task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context,
144
tags, task.Due, task.Modified, task.End, task.Start, annotations, task.ID)
145
if err != nil {
146
return fmt.Errorf("failed to update task: %w", err)
···
183
}
184
185
func (r *TaskRepository) buildListQuery(opts TaskListOptions) string {
186
+
query := "SELECT id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations FROM tasks"
187
188
var conditions []string
189
···
196
if opts.Project != "" {
197
conditions = append(conditions, "project = ?")
198
}
199
+
if opts.Context != "" {
200
+
conditions = append(conditions, "context = ?")
201
+
}
202
if !opts.DueAfter.IsZero() {
203
conditions = append(conditions, "due >= ?")
204
}
···
210
searchConditions := []string{
211
"description LIKE ?",
212
"project LIKE ?",
213
+
"context LIKE ?",
214
"tags LIKE ?",
215
}
216
conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
···
252
if opts.Project != "" {
253
args = append(args, opts.Project)
254
}
255
+
if opts.Context != "" {
256
+
args = append(args, opts.Context)
257
+
}
258
if !opts.DueAfter.IsZero() {
259
args = append(args, opts.DueAfter)
260
}
···
264
265
if opts.Search != "" {
266
searchPattern := "%" + opts.Search + "%"
267
+
args = append(args, searchPattern, searchPattern, searchPattern, searchPattern)
268
}
269
270
return args
···
274
var tags, annotations sql.NullString
275
276
if err := rows.Scan(&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority,
277
+
&task.Project, &task.Context, &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil {
278
return fmt.Errorf("failed to scan task row: %w", err)
279
}
280
···
395
return r.List(ctx, TaskListOptions{Project: project})
396
}
397
398
+
// GetByContext retrieves all tasks for a specific context
399
+
func (r *TaskRepository) GetByContext(ctx context.Context, context string) ([]*models.Task, error) {
400
+
return r.List(ctx, TaskListOptions{Context: context})
401
+
}
402
+
403
// GetProjects retrieves all unique project names with their task counts
404
func (r *TaskRepository) GetProjects(ctx context.Context) ([]ProjectSummary, error) {
405
query := `
···
454
return tags, rows.Err()
455
}
456
457
+
// GetContexts retrieves all unique context names with their task counts
458
+
func (r *TaskRepository) GetContexts(ctx context.Context) ([]ContextSummary, error) {
459
+
query := `
460
+
SELECT context, COUNT(*) as task_count
461
+
FROM tasks
462
+
WHERE context != '' AND context IS NOT NULL
463
+
GROUP BY context
464
+
ORDER BY context`
465
+
466
+
rows, err := r.db.QueryContext(ctx, query)
467
+
if err != nil {
468
+
return nil, fmt.Errorf("failed to get contexts: %w", err)
469
+
}
470
+
defer rows.Close()
471
+
472
+
var contexts []ContextSummary
473
+
for rows.Next() {
474
+
var context ContextSummary
475
+
if err := rows.Scan(&context.Name, &context.TaskCount); err != nil {
476
+
return nil, fmt.Errorf("failed to scan context row: %w", err)
477
+
}
478
+
contexts = append(contexts, context)
479
+
}
480
+
481
+
return contexts, rows.Err()
482
+
}
483
+
484
// GetTasksByTag retrieves all tasks with a specific tag
485
func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) {
486
query := `
487
+
SELECT tasks.id, tasks.uuid, tasks.description, tasks.status, tasks.priority, tasks.project, tasks.context, tasks.tags, tasks.due, tasks.entry, tasks.modified, tasks.end, tasks.start, tasks.annotations
488
FROM tasks, json_each(tasks.tags)
489
WHERE tasks.tags != '' AND tasks.tags IS NOT NULL AND json_each.value = ?
490
ORDER BY tasks.modified DESC`
···
538
func (r *TaskRepository) GetByPriority(ctx context.Context, priority string) ([]*models.Task, error) {
539
if priority == "" {
540
query := `
541
+
SELECT id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations
542
FROM tasks
543
WHERE priority = '' OR priority IS NULL
544
ORDER BY modified DESC`
+126
internal/repo/task_repository_test.go
···
30
status TEXT DEFAULT 'pending',
31
priority TEXT,
32
project TEXT,
0
33
tags TEXT,
34
due DATETIME,
35
entry DATETIME DEFAULT CURRENT_TIMESTAMP,
···
58
Status: "pending",
59
Priority: "H",
60
Project: "test-project",
0
61
Tags: []string{"test", "important"},
62
Annotations: []string{"This is a test", "Another annotation"},
63
}
···
122
}
123
if retrieved.Project != original.Project {
124
t.Errorf("Expected project %s, got %s", original.Project, retrieved.Project)
0
0
0
125
}
126
127
if len(retrieved.Tags) != len(original.Tags) {
···
821
})
822
})
823
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
30
status TEXT DEFAULT 'pending',
31
priority TEXT,
32
project TEXT,
33
+
context TEXT,
34
tags TEXT,
35
due DATETIME,
36
entry DATETIME DEFAULT CURRENT_TIMESTAMP,
···
59
Status: "pending",
60
Priority: "H",
61
Project: "test-project",
62
+
Context: "test-context",
63
Tags: []string{"test", "important"},
64
Annotations: []string{"This is a test", "Another annotation"},
65
}
···
124
}
125
if retrieved.Project != original.Project {
126
t.Errorf("Expected project %s, got %s", original.Project, retrieved.Project)
127
+
}
128
+
if retrieved.Context != original.Context {
129
+
t.Errorf("Expected context %s, got %s", original.Context, retrieved.Context)
130
}
131
132
if len(retrieved.Tags) != len(original.Tags) {
···
826
})
827
})
828
}
829
+
830
+
func TestTaskRepository_GetContexts(t *testing.T) {
831
+
db := createTaskTestDB(t)
832
+
repo := NewTaskRepository(db)
833
+
ctx := context.Background()
834
+
835
+
// Create tasks with different contexts
836
+
task1 := createSampleTask()
837
+
task1.Context = "work"
838
+
_, err := repo.Create(ctx, task1)
839
+
if err != nil {
840
+
t.Fatalf("Failed to create task1: %v", err)
841
+
}
842
+
843
+
task2 := createSampleTask()
844
+
task2.Context = "home"
845
+
_, err = repo.Create(ctx, task2)
846
+
if err != nil {
847
+
t.Fatalf("Failed to create task2: %v", err)
848
+
}
849
+
850
+
task3 := createSampleTask()
851
+
task3.Context = "work"
852
+
_, err = repo.Create(ctx, task3)
853
+
if err != nil {
854
+
t.Fatalf("Failed to create task3: %v", err)
855
+
}
856
+
857
+
// Task with empty context should not be included
858
+
task4 := createSampleTask()
859
+
task4.Context = ""
860
+
_, err = repo.Create(ctx, task4)
861
+
if err != nil {
862
+
t.Fatalf("Failed to create task4: %v", err)
863
+
}
864
+
865
+
contexts, err := repo.GetContexts(ctx)
866
+
if err != nil {
867
+
t.Fatalf("Failed to get contexts: %v", err)
868
+
}
869
+
870
+
if len(contexts) != 2 {
871
+
t.Errorf("Expected 2 contexts, got %d", len(contexts))
872
+
}
873
+
874
+
expectedCounts := map[string]int{
875
+
"home": 1,
876
+
"work": 2,
877
+
}
878
+
879
+
for _, context := range contexts {
880
+
expected, exists := expectedCounts[context.Name]
881
+
if !exists {
882
+
t.Errorf("Unexpected context: %s", context.Name)
883
+
}
884
+
if context.TaskCount != expected {
885
+
t.Errorf("Expected %d tasks for context %s, got %d", expected, context.Name, context.TaskCount)
886
+
}
887
+
}
888
+
}
889
+
890
+
func TestTaskRepository_GetByContext(t *testing.T) {
891
+
db := createTaskTestDB(t)
892
+
repo := NewTaskRepository(db)
893
+
ctx := context.Background()
894
+
895
+
// Create tasks with different contexts
896
+
task1 := createSampleTask()
897
+
task1.Context = "work"
898
+
task1.Description = "Work task 1"
899
+
_, err := repo.Create(ctx, task1)
900
+
if err != nil {
901
+
t.Fatalf("Failed to create task1: %v", err)
902
+
}
903
+
904
+
task2 := createSampleTask()
905
+
task2.Context = "home"
906
+
task2.Description = "Home task 1"
907
+
_, err = repo.Create(ctx, task2)
908
+
if err != nil {
909
+
t.Fatalf("Failed to create task2: %v", err)
910
+
}
911
+
912
+
task3 := createSampleTask()
913
+
task3.Context = "work"
914
+
task3.Description = "Work task 2"
915
+
_, err = repo.Create(ctx, task3)
916
+
if err != nil {
917
+
t.Fatalf("Failed to create task3: %v", err)
918
+
}
919
+
920
+
// Get tasks by work context
921
+
workTasks, err := repo.GetByContext(ctx, "work")
922
+
if err != nil {
923
+
t.Fatalf("Failed to get tasks by context: %v", err)
924
+
}
925
+
926
+
if len(workTasks) != 2 {
927
+
t.Errorf("Expected 2 work tasks, got %d", len(workTasks))
928
+
}
929
+
930
+
for _, task := range workTasks {
931
+
if task.Context != "work" {
932
+
t.Errorf("Expected context 'work', got '%s'", task.Context)
933
+
}
934
+
}
935
+
936
+
// Get tasks by home context
937
+
homeTasks, err := repo.GetByContext(ctx, "home")
938
+
if err != nil {
939
+
t.Fatalf("Failed to get tasks by context: %v", err)
940
+
}
941
+
942
+
if len(homeTasks) != 1 {
943
+
t.Errorf("Expected 1 home task, got %d", len(homeTasks))
944
+
}
945
+
946
+
if homeTasks[0].Context != "home" {
947
+
t.Errorf("Expected context 'home', got '%s'", homeTasks[0].Context)
948
+
}
949
+
}
+3
internal/store/sql/migrations/0005_add_context_to_tasks_down.sql
···
0
0
0
···
1
+
-- Remove context field and index
2
+
DROP INDEX IF EXISTS idx_tasks_context;
3
+
ALTER TABLE tasks DROP COLUMN context;
+5
internal/store/sql/migrations/0005_add_context_to_tasks_up.sql
···
0
0
0
0
0
···
1
+
-- Add context field to tasks table
2
+
ALTER TABLE tasks ADD COLUMN context TEXT;
3
+
4
+
-- Add index for context queries
5
+
CREATE INDEX IF NOT EXISTS idx_tasks_context ON tasks(context);
+1
internal/ui/task_list.go
···
91
Status string
92
Priority string
93
Project string
0
94
ShowAll bool
95
}
96
···
91
Status string
92
Priority string
93
Project string
94
+
Context string
95
ShowAll bool
96
}
97