cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: add contexts to task domain

+381 -127
+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") 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") 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 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") 61 62 return cmd 63 } ··· 94 status, _ := cmd.Flags().GetString("status") 95 priority, _ := cmd.Flags().GetString("priority") 96 project, _ := cmd.Flags().GetString("project") 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") 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 234 }, 235 } 236 } 237 238 func taskCompleteCmd(h *handlers.TaskHandler) *cobra.Command {
··· 1 package main 2 3 import ( 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, 68 Tags: tags, 69 } 70 ··· 88 } 89 if project != "" { 90 fmt.Printf("Project: %s\n", project) 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, 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, 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 178 } 179 if due != "" { 180 if dueTime, err := time.Parse("2006-01-02", due); err == nil { ··· 556 return h.listTagsInteractive(ctx) 557 } 558 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 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) 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) 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 - } 363 }) 364 }
··· 261 } 262 }) 263 }) 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 + }) 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"` 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, 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 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 { 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 } 192 if !opts.DueAfter.IsZero() { 193 conditions = append(conditions, "due >= ?") 194 } ··· 200 searchConditions := []string{ 201 "description LIKE ?", 202 "project LIKE ?", 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 } 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 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 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, 33 tags TEXT, 34 due DATETIME, 35 entry DATETIME DEFAULT CURRENT_TIMESTAMP, ··· 58 Status: "pending", 59 Priority: "H", 60 Project: "test-project", 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) 125 } 126 127 if len(retrieved.Tags) != len(original.Tags) { ··· 821 }) 822 }) 823 }
··· 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
···
··· 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
···
··· 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 94 ShowAll bool 95 } 96
··· 91 Status string 92 Priority string 93 Project string 94 + Context string 95 ShowAll bool 96 } 97