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 1 package main 2 2 3 3 import ( 4 - "fmt" 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 + 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 - return h.Create(c.Context(), args, priority, project, due, tags) 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 + 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 + context, _ := c.Flags().GetString("context") 50 51 51 52 defer h.Close() 52 - return h.List(c.Context(), static, showAll, status, priority, project) 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 + 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 + 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 - return handler.Update(cmd.Context(), taskID, description, status, priority, project, due, addTags, removeTags) 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 + 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 - func taskContextsCmd(*handlers.TaskHandler) *cobra.Command { 227 - return &cobra.Command{ 230 + func taskContextsCmd(h *handlers.TaskHandler) *cobra.Command { 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 - fmt.Println("Listing task contexts...") 233 - return nil 236 + static, _ := c.Flags().GetBool("static") 237 + 238 + defer h.Close() 239 + return h.ListContexts(c.Context(), static) 234 240 }, 235 241 } 242 + cmd.Flags().Bool("static", false, "Use static text output instead of interactive") 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 - func (h *TaskHandler) Create(ctx context.Context, desc []string, priority, project, due string, tags []string) error { 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 + 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 + } 93 + if context != "" { 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 - func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project string) error { 107 + func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project, context string) error { 104 108 if static { 105 - return h.listTasksStatic(ctx, showAll, status, priority, project) 109 + return h.listTasksStatic(ctx, showAll, status, priority, project, context) 106 110 } 107 111 108 - return h.listTasksInteractive(ctx, showAll, status, priority, project) 112 + return h.listTasksInteractive(ctx, showAll, status, priority, project, context) 109 113 } 110 114 111 - func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project string) error { 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 + Context: context, 116 121 } 117 122 118 123 if !showAll && opts.Status == "" { ··· 137 142 return nil 138 143 } 139 144 140 - func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project string) error { 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 + 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 - func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, priority, project, due string, addTags, removeTags []string) error { 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 + } 185 + if context != "" { 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 + // 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 + 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 + if task.Context != "" { 668 + fmt.Printf(" @%s", task.Context) 669 + } 670 + 612 671 if len(task.Tags) > 0 { 613 - fmt.Printf(" @%s", strings.Join(task.Tags, " @")) 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 + } 695 + 696 + if task.Context != "" { 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 - err := handler.Create(ctx, args, "", "", "", []string{}) 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 - err := handler.Create(ctx, args, "", "", "", []string{}) 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 - err := handler.Create(ctx, args, priority, project, due, tags) 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 - err := handler.Create(ctx, args, "", "", invalidDue, []string{}) 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 - err := handler.List(ctx, true, false, "", "", "") 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 - err := handler.List(ctx, true, false, "completed", "", "") 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 - err := handler.List(ctx, true, false, "", "A", "") 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 - err := handler.List(ctx, true, false, "", "", "work") 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 - err := handler.List(ctx, true, true, "", "", "") 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 - err := handler.Update(ctx, taskID, "Updated description", "", "", "", "", []string{}, []string{}) 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 - err := handler.Update(ctx, taskID, "", "completed", "", "", "", []string{}, []string{}) 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 - err := handler.Update(ctx, taskID, "Multiple updates", "", "B", "test", "2024-12-31", []string{}, []string{}) 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 - err := handler.Update(ctx, taskID, "", "", "", "", "", []string{"work", "urgent"}, []string{}) 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 - err = handler.Update(ctx, taskID, "", "", "", "", "", []string{}, []string{"urgent"}) 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 - err := handler.Update(ctx, "", "", "", "", "", "", []string{}, []string{}) 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 - err := handler.Update(ctx, taskID, "test", "", "", "", "", []string{}, []string{}) 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 - } 265 264 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 - } 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 + } 280 279 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) 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 285 } 286 - } 287 - } 286 + }) 288 287 289 - func TestTimeEntryMethods(t *testing.T) { 290 - now := time.Now() 288 + t.Run("TestTimeEntryMethods", func(t *testing.T) { 289 + now := time.Now() 291 290 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 - } 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 + } 297 296 298 - if !entry.IsActive() { 299 - t.Error("Expected entry to be active") 300 - } 301 - }) 297 + if !entry.IsActive() { 298 + t.Error("Expected entry to be active") 299 + } 300 + }) 302 301 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 - } 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 + } 309 308 310 - if entry.IsActive() { 311 - t.Error("Expected entry to not be active") 312 - } 313 - }) 309 + if entry.IsActive() { 310 + t.Error("Expected entry to not be active") 311 + } 312 + }) 314 313 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 - } 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 + } 320 319 321 - entry.Stop() 320 + entry.Stop() 322 321 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 - }) 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 + }) 333 332 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 - } 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 + } 342 341 343 - duration := entry.GetDuration() 344 - expected := 2 * time.Hour 342 + duration := entry.GetDuration() 343 + expected := 2 * time.Hour 345 344 346 - if duration != expected { 347 - t.Errorf("Expected duration %v, got %v", expected, duration) 348 - } 349 - }) 345 + if duration != expected { 346 + t.Errorf("Expected duration %v, got %v", expected, duration) 347 + } 348 + }) 350 349 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 - } 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 + } 357 356 358 - duration := entry.GetDuration() 357 + duration := entry.GetDuration() 359 358 360 - if duration < 59*time.Second || duration > 61*time.Second { 361 - t.Errorf("Expected duration around 1 minute, got %v", duration) 362 - } 359 + if duration < 59*time.Second || duration > 61*time.Second { 360 + t.Errorf("Expected duration around 1 minute, got %v", duration) 361 + } 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 + 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 + 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 + 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 + 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 { 35 42 Name string `json:"name"` 36 43 TaskCount int `json:"task_count"` 37 44 } ··· 63 70 } 64 71 65 72 query := ` 66 - INSERT INTO tasks (uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations) 67 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 73 + INSERT INTO tasks (uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations) 74 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 68 75 69 76 result, err := r.db.ExecContext(ctx, query, 70 - task.UUID, task.Description, task.Status, task.Priority, task.Project, 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 - SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations 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 - &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project, 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 - UPDATE tasks SET uuid = ?, description = ?, status = ?, priority = ?, project = ?, 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 - task.UUID, task.Description, task.Status, task.Priority, task.Project, 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 - query := "SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations FROM tasks" 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 + if opts.Context != "" { 200 + conditions = append(conditions, "context = ?") 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 + "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 + if opts.Context != "" { 256 + args = append(args, opts.Context) 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 - args = append(args, searchPattern, searchPattern, searchPattern) 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 - &task.Project, &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil { 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 + // 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 + 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 + // 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 + 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 - 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 + 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 - SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations 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 + 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 + 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 + } 128 + if retrieved.Context != original.Context { 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 + 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 91 Status string 92 92 Priority string 93 93 Project string 94 + Context string 94 95 ShowAll bool 95 96 } 96 97