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