// TODO: add context field to table in [TaskHandler.listTasksInteractive] package handlers import ( "context" "fmt" "os" "slices" "sort" "strconv" "strings" "time" "github.com/google/uuid" "github.com/stormlightlabs/noteleaf/internal/models" "github.com/stormlightlabs/noteleaf/internal/repo" "github.com/stormlightlabs/noteleaf/internal/store" "github.com/stormlightlabs/noteleaf/internal/ui" ) // TaskHandler handles all task-related commands type TaskHandler struct { db *store.Database config *store.Config repos *repo.Repositories } // NewTaskHandler creates a new task handler func NewTaskHandler() (*TaskHandler, error) { db, err := store.NewDatabase() if err != nil { return nil, fmt.Errorf("failed to initialize database: %w", err) } config, err := store.LoadConfig() if err != nil { return nil, fmt.Errorf("failed to load configuration: %w", err) } repos := repo.NewRepositories(db.DB) return &TaskHandler{ db: db, config: config, repos: repos, }, nil } // Close cleans up resources func (h *TaskHandler) Close() error { return h.db.Close() } // Create creates a new task func (h *TaskHandler) Create(ctx context.Context, description, priority, project, context, due, wait, scheduled, recur, until, parentUUID, dependsOn string, tags []string) error { if description == "" { return fmt.Errorf("task description required") } parsed := parseDescription(description) if project != "" { parsed.Project = project } if context != "" { parsed.Context = context } if due != "" { parsed.Due = due } if wait != "" { parsed.Wait = wait } if scheduled != "" { parsed.Scheduled = scheduled } if recur != "" { parsed.Recur = recur } if until != "" { parsed.Until = until } if parentUUID != "" { parsed.ParentUUID = parentUUID } if dependsOn != "" { parsed.DependsOn = strings.Split(dependsOn, ",") } if len(tags) > 0 { parsed.Tags = append(parsed.Tags, tags...) } task := &models.Task{ UUID: uuid.New().String(), Description: parsed.Description, Status: "pending", Priority: priority, Project: parsed.Project, Context: parsed.Context, Tags: parsed.Tags, Recur: models.RRule(parsed.Recur), DependsOn: parsed.DependsOn, } if parsed.Due != "" { if dueTime, err := time.Parse("2006-01-02", parsed.Due); err == nil { task.Due = &dueTime } else { return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) } } if parsed.Wait != "" { if waitTime, err := time.Parse("2006-01-02", parsed.Wait); err == nil { task.Wait = &waitTime } else { return fmt.Errorf("invalid wait date format, use YYYY-MM-DD: %w", err) } } if parsed.Scheduled != "" { if scheduledTime, err := time.Parse("2006-01-02", parsed.Scheduled); err == nil { task.Scheduled = &scheduledTime } else { return fmt.Errorf("invalid scheduled date format, use YYYY-MM-DD: %w", err) } } if parsed.Until != "" { if untilTime, err := time.Parse("2006-01-02", parsed.Until); err == nil { task.Until = &untilTime } else { return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) } } if parsed.ParentUUID != "" { task.ParentUUID = &parsed.ParentUUID } id, err := h.repos.Tasks.Create(ctx, task) if err != nil { return fmt.Errorf("failed to create task: %w", err) } fmt.Printf("Task created (ID: %d, UUID: %s): %s\n", id, task.UUID, task.Description) if priority != "" { fmt.Printf("Priority: %s\n", priority) } if task.Project != "" { fmt.Printf("Project: %s\n", task.Project) } if task.Context != "" { fmt.Printf("Context: %s\n", task.Context) } if len(task.Tags) > 0 { fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", ")) } if task.Due != nil { fmt.Printf("Due: %s\n", task.Due.Format("2006-01-02")) } if task.Recur != "" { fmt.Printf("Recur: %s\n", task.Recur) } if task.Until != nil { fmt.Printf("Until: %s\n", task.Until.Format("2006-01-02")) } if task.ParentUUID != nil { fmt.Printf("Parent: %s\n", *task.ParentUUID) } if len(task.DependsOn) > 0 { fmt.Printf("Depends on: %s\n", strings.Join(task.DependsOn, ", ")) } return nil } // List lists all tasks with optional filtering func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project, context, sortBy string) error { if static { return h.listTasksStatic(ctx, showAll, status, priority, project, context, sortBy) } return h.listTasksInteractive(ctx, showAll, status, priority, project, context) } func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project, context, sortBy string) error { opts := repo.TaskListOptions{ Status: status, Priority: priority, Project: project, Context: context, } if !showAll && opts.Status == "" { opts.Status = "pending" } tasks, err := h.repos.Tasks.List(ctx, opts) if err != nil { return fmt.Errorf("failed to list tasks: %w", err) } if sortBy == "urgency" { now := time.Now() sort.Slice(tasks, func(i, j int) bool { return tasks[i].Urgency(now) > tasks[j].Urgency(now) }) } if len(tasks) == 0 { fmt.Printf("No tasks found matching criteria\n") return nil } fmt.Printf("Found %d task(s)", len(tasks)) if sortBy == "urgency" { fmt.Printf(" (sorted by urgency)") } fmt.Printf(":\n\n") for _, task := range tasks { if sortBy == "urgency" { urgency := task.Urgency(time.Now()) fmt.Printf("[%.1f] ", urgency) } printTask(task) } return nil } func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project, _ string) error { taskTable := ui.NewTaskListFromTable(h.repos.Tasks, os.Stdout, os.Stdin, false, showAll, status, priority, project) return taskTable.Browse(ctx) } // Update updates a task using parsed flag values func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, priority, project, context, due, recur, until, parentUUID string, addTags, removeTags []string, addDeps, removeDeps string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } if description != "" { task.Description = description } if status != "" { task.Status = status } if priority != "" { task.Priority = priority } if project != "" { task.Project = project } if context != "" { task.Context = context } if due != "" { if dueTime, err := time.Parse("2006-01-02", due); err == nil { task.Due = &dueTime } else { return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) } } if recur != "" { task.Recur = models.RRule(recur) } if until != "" { if untilTime, err := time.Parse("2006-01-02", until); err == nil { task.Until = &untilTime } else { return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) } } if parentUUID != "" { task.ParentUUID = &parentUUID } for _, tag := range addTags { if !slices.Contains(task.Tags, tag) { task.Tags = append(task.Tags, tag) } } for _, tag := range removeTags { task.Tags = removeString(task.Tags, tag) } if addDeps != "" { deps := strings.SplitSeq(addDeps, ",") for dep := range deps { dep = strings.TrimSpace(dep) if dep != "" && !slices.Contains(task.DependsOn, dep) { task.DependsOn = append(task.DependsOn, dep) } } } if removeDeps != "" { deps := strings.SplitSeq(removeDeps, ",") for dep := range deps { dep = strings.TrimSpace(dep) task.DependsOn = removeString(task.DependsOn, dep) } } err = h.repos.Tasks.Update(ctx, task) if err != nil { return fmt.Errorf("failed to update task: %w", err) } fmt.Printf("Task updated (ID: %d): %s\n", task.ID, task.Description) return nil } // EditInteractive opens an interactive task editor with status picker and priority toggle func (h *TaskHandler) EditInteractive(ctx context.Context, taskID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } editor := ui.NewTaskEditor(task, h.repos.Tasks, ui.TaskEditOptions{}) updated, err := editor.Edit(ctx) if err != nil { if err.Error() == "edit cancelled" { fmt.Println("Task edit cancelled") return nil } return fmt.Errorf("failed to edit task: %w", err) } fmt.Printf("Task updated (ID: %d): %s\n", updated.ID, updated.Description) fmt.Printf("Status: %s\n", ui.FormatStatusWithText(updated.Status)) if updated.Priority != "" { fmt.Printf("Priority: %s\n", ui.FormatPriorityWithText(updated.Priority)) } if updated.Project != "" { fmt.Printf("Project: %s\n", updated.Project) } return nil } // Delete deletes a task func (h *TaskHandler) Delete(ctx context.Context, args []string) error { if len(args) < 1 { return fmt.Errorf("task ID required") } taskID := args[0] var task *models.Task var err error if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { task, err = h.repos.Tasks.Get(ctx, id) if err != nil { return fmt.Errorf("failed to find task: %w", err) } err = h.repos.Tasks.Delete(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) if err != nil { return fmt.Errorf("failed to find task: %w", err) } err = h.repos.Tasks.Delete(ctx, task.ID) } if err != nil { return fmt.Errorf("failed to delete task: %w", err) } fmt.Printf("Task deleted (ID: %d): %s\n", task.ID, task.Description) return nil } // View displays a single task func (h *TaskHandler) View(ctx context.Context, args []string, format string, jsonOutput, noMetadata bool) error { if len(args) < 1 { return fmt.Errorf("task ID required") } taskID := args[0] var task *models.Task var err error if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } if jsonOutput { return printTaskJSON(task) } if format == "brief" { printTask(task) } else { printTaskDetail(task, noMetadata) } return nil } // Start starts time tracking for a task func (h *TaskHandler) Start(ctx context.Context, taskID string, description string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } active, err := h.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) if err != nil && err.Error() != "sql: no rows in result set" { return fmt.Errorf("failed to check active time entry: %w", err) } if active != nil { duration := time.Since(active.StartTime) fmt.Printf("Task already started %s ago: %s\n", formatDuration(duration), task.Description) return nil } _, err = h.repos.TimeEntries.Start(ctx, task.ID, description) if err != nil { return fmt.Errorf("failed to start time tracking: %w", err) } fmt.Printf("Started task (ID: %d): %s\n", task.ID, task.Description) if description != "" { fmt.Printf("Note: %s\n", description) } return nil } // Stop stops time tracking for a task func (h *TaskHandler) Stop(ctx context.Context, taskID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } entry, err := h.repos.TimeEntries.StopActiveByTaskID(ctx, task.ID) if err != nil { if err.Error() == "no active time entry found for task" { fmt.Printf("No active time tracking for task: %s\n", task.Description) return nil } return fmt.Errorf("failed to stop time tracking: %w", err) } fmt.Printf("Stopped task (ID: %d): %s\n", task.ID, task.Description) fmt.Printf("Time tracked: %s\n", formatDuration(entry.GetDuration())) return nil } // Timesheet shows time tracking summary func (h *TaskHandler) Timesheet(ctx context.Context, days int, taskID string) error { var entries []*models.TimeEntry var err error if taskID != "" { var task *models.Task if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } entries, err = h.repos.TimeEntries.GetByTaskID(ctx, task.ID) if err != nil { return fmt.Errorf("failed to get time entries: %w", err) } fmt.Printf("Timesheet for task: %s\n\n", task.Description) } else { end := time.Now() start := end.AddDate(0, 0, -days) entries, err = h.repos.TimeEntries.GetByDateRange(ctx, start, end) if err != nil { return fmt.Errorf("failed to get time entries: %w", err) } fmt.Printf("Timesheet for last %d days:\n\n", days) } if len(entries) == 0 { fmt.Printf("No time entries found\n") return nil } taskTotals := make(map[int64]time.Duration) dayTotals := make(map[string]time.Duration) totalTime := time.Duration(0) fmt.Printf("%-20s %-10s %-12s %-40s %s\n", "Date", "Duration", "Status", "Task", "Note") fmt.Printf("%s\n", strings.Repeat("-", 95)) for _, entry := range entries { task, err := h.repos.Tasks.Get(ctx, entry.TaskID) if err != nil { continue } duration := entry.GetDuration() day := entry.StartTime.Format("2006-01-02") status := "completed" if entry.IsActive() { status = "active" } taskTotals[entry.TaskID] += duration dayTotals[day] += duration totalTime += duration note := entry.Description if len(note) > 35 { note = note[:32] + "..." } taskDesc := task.Description if len(taskDesc) > 37 { taskDesc = taskDesc[:34] + "..." } fmt.Printf("%-20s %-10s %-12s %-40s %s\n", day, formatDuration(duration), status, fmt.Sprintf("[%d] %s", task.ID, taskDesc), note, ) } fmt.Printf("%s\n", strings.Repeat("-", 95)) fmt.Printf("Total time: %s\n", formatDuration(totalTime)) return nil } // Done marks a task as completed func (h *TaskHandler) Done(ctx context.Context, args []string) error { if len(args) < 1 { return fmt.Errorf("task ID required") } taskID := args[0] var task *models.Task var err error if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } if task.Status == "completed" { fmt.Printf("Task already completed: %s\n", task.Description) return nil } now := time.Now() task.Status = "completed" task.End = &now err = h.repos.Tasks.Update(ctx, task) if err != nil { return fmt.Errorf("failed to update task: %w", err) } fmt.Printf("Task completed (ID: %d): %s\n", task.ID, task.Description) return nil } // ListProjects lists all projects with their task counts func (h *TaskHandler) ListProjects(ctx context.Context, static bool, todoTxt ...bool) error { useTodoTxt := len(todoTxt) > 0 && todoTxt[0] if static { return h.listProjectsStatic(ctx, useTodoTxt) } return h.listProjectsInteractive(ctx, useTodoTxt) } func (h *TaskHandler) listProjectsStatic(ctx context.Context, todoTxt bool) error { tasks, err := h.repos.Tasks.List(ctx, repo.TaskListOptions{}) if err != nil { return fmt.Errorf("failed to list tasks for projects: %w", err) } projectCounts := make(map[string]int) for _, task := range tasks { if task.Project != "" { projectCounts[task.Project]++ } } if len(projectCounts) == 0 { fmt.Printf("No projects found\n") return nil } projects := make([]string, 0, len(projectCounts)) for project := range projectCounts { projects = append(projects, project) } slices.Sort(projects) fmt.Printf("Found %d project(s):\n\n", len(projects)) for _, project := range projects { count := projectCounts[project] if todoTxt { fmt.Printf("+%s (%d task%s)\n", project, count, pluralize(count)) } else { fmt.Printf("%s (%d task%s)\n", project, count, pluralize(count)) } } return nil } // TODO: Add todo.txt format support to interactive mode func (h *TaskHandler) listProjectsInteractive(ctx context.Context, _ bool) error { projectTable := ui.NewProjectListFromTable(h.repos.Tasks, nil, nil, false) return projectTable.Browse(ctx) } // ListTags lists all tags with their task counts func (h *TaskHandler) ListTags(ctx context.Context, static bool) error { if static { return h.listTagsStatic(ctx) } return h.listTagsInteractive(ctx) } // ListContexts lists all contexts with their task counts func (h *TaskHandler) ListContexts(ctx context.Context, static bool, todoTxt ...bool) error { useTodoTxt := len(todoTxt) > 0 && todoTxt[0] if static { return h.listContextsStatic(ctx, useTodoTxt) } return h.listContextsInteractive(ctx, useTodoTxt) } func (h *TaskHandler) listContextsStatic(ctx context.Context, todoTxt bool) error { tasks, err := h.repos.Tasks.List(ctx, repo.TaskListOptions{}) if err != nil { return fmt.Errorf("failed to list tasks for contexts: %w", err) } contextCounts := make(map[string]int) for _, task := range tasks { if task.Context != "" { contextCounts[task.Context]++ } } if len(contextCounts) == 0 { fmt.Printf("No contexts found\n") return nil } contexts := make([]string, 0, len(contextCounts)) for context := range contextCounts { contexts = append(contexts, context) } slices.Sort(contexts) fmt.Printf("Found %d context(s):\n\n", len(contexts)) for _, context := range contexts { count := contextCounts[context] if todoTxt { fmt.Printf("@%s (%d task%s)\n", context, count, pluralize(count)) } else { fmt.Printf("%s (%d task%s)\n", context, count, pluralize(count)) } } return nil } func (h *TaskHandler) listContextsInteractive(ctx context.Context, todoTxt bool) error { fmt.Println("Interactive context listing not implemented yet - using static mode") return h.listContextsStatic(ctx, todoTxt) } func (h *TaskHandler) listTagsStatic(ctx context.Context) error { tasks, err := h.repos.Tasks.List(ctx, repo.TaskListOptions{}) if err != nil { return fmt.Errorf("failed to list tasks for tags: %w", err) } tagCounts := make(map[string]int) for _, task := range tasks { for _, tag := range task.Tags { tagCounts[tag]++ } } if len(tagCounts) == 0 { fmt.Printf("No tags found\n") return nil } tags := make([]string, 0, len(tagCounts)) for tag := range tagCounts { tags = append(tags, tag) } slices.Sort(tags) fmt.Printf("Found %d tag(s):\n\n", len(tags)) for _, tag := range tags { count := tagCounts[tag] fmt.Printf("%s (%d task%s)\n", tag, count, pluralize(count)) } return nil } func (h *TaskHandler) listTagsInteractive(ctx context.Context) error { tagTable := ui.NewTagListFromTable(h.repos.Tasks, nil, nil, false) return tagTable.Browse(ctx) } // SetRecur sets the recurrence rule for a task func (h *TaskHandler) SetRecur(ctx context.Context, taskID, rule, until string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } if rule != "" { task.Recur = models.RRule(rule) } if until != "" { if untilTime, err := time.Parse("2006-01-02", until); err == nil { task.Until = &untilTime } else { return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) } } err = h.repos.Tasks.Update(ctx, task) if err != nil { return fmt.Errorf("failed to update task recurrence: %w", err) } fmt.Printf("Recurrence set for task (ID: %d): %s\n", task.ID, task.Description) if task.Recur != "" { fmt.Printf("Rule: %s\n", task.Recur) } if task.Until != nil { fmt.Printf("Until: %s\n", task.Until.Format("2006-01-02")) } return nil } // ClearRecur clears the recurrence rule from a task func (h *TaskHandler) ClearRecur(ctx context.Context, taskID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } task.Recur = "" task.Until = nil err = h.repos.Tasks.Update(ctx, task) if err != nil { return fmt.Errorf("failed to clear task recurrence: %w", err) } fmt.Printf("Recurrence cleared for task (ID: %d): %s\n", task.ID, task.Description) return nil } // ShowRecur displays the recurrence details for a task func (h *TaskHandler) ShowRecur(ctx context.Context, taskID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) if task.Recur != "" { fmt.Printf("Recurrence rule: %s\n", task.Recur) if task.Until != nil { fmt.Printf("Recurrence until: %s\n", task.Until.Format("2006-01-02")) } else { fmt.Printf("Recurrence until: (no end date)\n") } } else { fmt.Printf("No recurrence set\n") } return nil } // AddDep adds a dependency to a task func (h *TaskHandler) AddDep(ctx context.Context, taskID, dependsOnUUID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } if _, err := h.repos.Tasks.GetByUUID(ctx, dependsOnUUID); err != nil { return fmt.Errorf("dependency task not found: %w", err) } err = h.repos.Tasks.AddDependency(ctx, task.UUID, dependsOnUUID) if err != nil { return fmt.Errorf("failed to add dependency: %w", err) } fmt.Printf("Dependency added to task (ID: %d): %s\n", task.ID, task.Description) fmt.Printf("Now depends on: %s\n", dependsOnUUID) return nil } // RemoveDep removes a dependency from a task func (h *TaskHandler) RemoveDep(ctx context.Context, taskID, dependsOnUUID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } err = h.repos.Tasks.RemoveDependency(ctx, task.UUID, dependsOnUUID) if err != nil { return fmt.Errorf("failed to remove dependency: %w", err) } fmt.Printf("Dependency removed from task (ID: %d): %s\n", task.ID, task.Description) fmt.Printf("No longer depends on: %s\n", dependsOnUUID) return nil } // ListDeps lists all dependencies for a task func (h *TaskHandler) ListDeps(ctx context.Context, taskID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) if len(task.DependsOn) == 0 { fmt.Printf("No dependencies\n") return nil } fmt.Printf("Depends on %d task(s):\n", len(task.DependsOn)) for _, depUUID := range task.DependsOn { depTask, err := h.repos.Tasks.GetByUUID(ctx, depUUID) if err != nil { fmt.Printf(" - %s (not found)\n", depUUID) continue } fmt.Printf(" - [%d] %s (UUID: %s)\n", depTask.ID, depTask.Description, depTask.UUID) } return nil } // BlockedByDep shows tasks that are blocked by the given task func (h *TaskHandler) BlockedByDep(ctx context.Context, taskID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) dependents, err := h.repos.Tasks.GetDependents(ctx, task.UUID) if err != nil { return fmt.Errorf("failed to get dependent tasks: %w", err) } if len(dependents) == 0 { fmt.Printf("No tasks are blocked by this task\n") return nil } fmt.Printf("Blocks %d task(s):\n", len(dependents)) for _, dep := range dependents { fmt.Printf(" - [%d] %s\n", dep.ID, dep.Description) } return nil } // NextActions shows actionable tasks sorted by urgency func (h *TaskHandler) NextActions(ctx context.Context, limit int) error { opts := repo.TaskListOptions{ SortBy: "urgency", SortOrder: "desc", } tasks, err := h.repos.Tasks.List(ctx, opts) if err != nil { return fmt.Errorf("failed to list tasks: %w", err) } now := time.Now() var actionable []*models.Task for _, task := range tasks { if task.IsActionable(now) { actionable = append(actionable, task) } } sort.Slice(actionable, func(i, j int) bool { return actionable[i].Urgency(now) > actionable[j].Urgency(now) }) if limit > 0 && len(actionable) > limit { actionable = actionable[:limit] } if len(actionable) == 0 { fmt.Println("No actionable tasks found") return nil } fmt.Printf("Next Actions (%d tasks, sorted by urgency):\n\n", len(actionable)) for i, task := range actionable { urgency := task.Urgency(now) fmt.Printf("%d. [Urgency: %.1f] ", i+1, urgency) printTask(task) } return nil } // ReportCompleted shows completed tasks func (h *TaskHandler) ReportCompleted(ctx context.Context, limit int) error { opts := repo.TaskListOptions{ Status: "done", SortBy: "modified", SortOrder: "desc", Limit: limit, } tasks, err := h.repos.Tasks.List(ctx, opts) if err != nil { return fmt.Errorf("failed to list completed tasks: %w", err) } if len(tasks) == 0 { fmt.Println("No completed tasks found") return nil } fmt.Printf("Completed Tasks (%d):\n\n", len(tasks)) for _, task := range tasks { fmt.Printf(" ") printTask(task) if task.End != nil { fmt.Printf(" Completed: %s\n", task.End.Format("2006-01-02 15:04")) } } return nil } // ReportWaiting shows tasks that are waiting func (h *TaskHandler) ReportWaiting(ctx context.Context) error { opts := repo.TaskListOptions{ SortBy: "wait", SortOrder: "asc", } tasks, err := h.repos.Tasks.List(ctx, opts) if err != nil { return fmt.Errorf("failed to list tasks: %w", err) } now := time.Now() var waiting []*models.Task for _, task := range tasks { if task.IsWaiting(now) { waiting = append(waiting, task) } } if len(waiting) == 0 { fmt.Println("No waiting tasks found") return nil } fmt.Printf("Waiting Tasks (%d):\n\n", len(waiting)) for _, task := range waiting { fmt.Printf(" ") printTask(task) if task.Wait != nil { daysUntil := int(task.Wait.Sub(now).Hours() / 24) fmt.Printf(" Wait until: %s (%d days)\n", task.Wait.Format("2006-01-02"), daysUntil) } } return nil } // ReportBlocked shows blocked tasks func (h *TaskHandler) ReportBlocked(ctx context.Context) error { opts := repo.TaskListOptions{ Status: "blocked", } tasks, err := h.repos.Tasks.List(ctx, opts) if err != nil { return fmt.Errorf("failed to list blocked tasks: %w", err) } if len(tasks) == 0 { fmt.Println("No blocked tasks found") return nil } fmt.Printf("Blocked Tasks (%d):\n\n", len(tasks)) for _, task := range tasks { fmt.Printf(" ") printTask(task) if len(task.DependsOn) > 0 { fmt.Printf(" Depends on %d task(s)\n", len(task.DependsOn)) } } return nil } // Calendar shows tasks by due date in a calendar-like view func (h *TaskHandler) Calendar(ctx context.Context, weeks int) error { if weeks <= 0 { weeks = 4 } now := time.Now() startDate := now.Truncate(24 * time.Hour) endDate := startDate.AddDate(0, 0, weeks*7) opts := repo.TaskListOptions{ SortBy: "due", SortOrder: "asc", } tasks, err := h.repos.Tasks.List(ctx, opts) if err != nil { return fmt.Errorf("failed to list tasks: %w", err) } tasksByDate := make(map[string][]*models.Task) overdue := []*models.Task{} for _, task := range tasks { if task.Due == nil || task.IsCompleted() || task.IsDone() { continue } dueDate := task.Due.Truncate(24 * time.Hour) if dueDate.Before(startDate) { overdue = append(overdue, task) } else if dueDate.Before(endDate) { dateKey := dueDate.Format("2006-01-02") tasksByDate[dateKey] = append(tasksByDate[dateKey], task) } } fmt.Printf("Calendar View (Next %d weeks)\n", weeks) fmt.Printf("Today: %s\n\n", now.Format("Monday, January 2, 2006")) if len(overdue) > 0 { fmt.Printf("OVERDUE (%d tasks):\n", len(overdue)) for _, task := range overdue { daysOverdue := int(now.Sub(*task.Due).Hours() / 24) fmt.Printf(" [%d days overdue] ", daysOverdue) printTask(task) } fmt.Println() } currentDate := startDate for currentDate.Before(endDate) { weekStart := currentDate weekEnd := currentDate.AddDate(0, 0, 6) weekTasks := 0 for d := weekStart; !d.After(weekEnd); d = d.AddDate(0, 0, 1) { dateKey := d.Format("2006-01-02") weekTasks += len(tasksByDate[dateKey]) } if weekTasks > 0 { fmt.Printf("Week of %s (%d tasks):\n", weekStart.Format("Jan 2"), weekTasks) for d := weekStart; !d.After(weekEnd); d = d.AddDate(0, 0, 1) { dateKey := d.Format("2006-01-02") dayTasks := tasksByDate[dateKey] if len(dayTasks) > 0 { dayName := d.Format("Monday, Jan 2") if d.Format("2006-01-02") == now.Format("2006-01-02") { dayName += " (TODAY)" } fmt.Printf(" %s:\n", dayName) for _, task := range dayTasks { fmt.Printf(" ") printTask(task) } } } fmt.Println() } currentDate = currentDate.AddDate(0, 0, 7) } if len(overdue) == 0 && len(tasksByDate) == 0 { fmt.Println("No tasks with due dates in the next", weeks, "weeks") } return nil } // Annotate adds an annotation to a task func (h *TaskHandler) Annotate(ctx context.Context, taskID, annotation string) error { if annotation == "" { return fmt.Errorf("annotation text required") } var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } task.Annotations = append(task.Annotations, annotation) err = h.repos.Tasks.Update(ctx, task) if err != nil { return fmt.Errorf("failed to update task: %w", err) } fmt.Printf("Annotation added to task (ID: %d): %s\n", task.ID, task.Description) fmt.Printf("Annotation: %s\n", annotation) return nil } // ListAnnotations lists all annotations for a task func (h *TaskHandler) ListAnnotations(ctx context.Context, taskID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) if len(task.Annotations) == 0 { fmt.Printf("No annotations\n") return nil } fmt.Printf("Annotations (%d):\n", len(task.Annotations)) for i, annotation := range task.Annotations { fmt.Printf(" %d. %s\n", i+1, annotation) } return nil } // RemoveAnnotation removes an annotation from a task by index func (h *TaskHandler) RemoveAnnotation(ctx context.Context, taskID string, index int) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } if len(task.Annotations) == 0 { return fmt.Errorf("task has no annotations") } if index < 1 || index > len(task.Annotations) { return fmt.Errorf("annotation index out of range (1-%d)", len(task.Annotations)) } annotation := task.Annotations[index-1] task.Annotations = append(task.Annotations[:index-1], task.Annotations[index:]...) err = h.repos.Tasks.Update(ctx, task) if err != nil { return fmt.Errorf("failed to update task: %w", err) } fmt.Printf("Annotation removed from task (ID: %d): %s\n", task.ID, task.Description) fmt.Printf("Removed: %s\n", annotation) return nil } // BulkEdit updates multiple tasks with the same changes func (h *TaskHandler) BulkEdit(ctx context.Context, taskIDs []string, status, priority, project, context string, tags []string, addTags, removeTags bool) error { if len(taskIDs) == 0 { return fmt.Errorf("no task IDs provided") } var ids []int64 for _, taskID := range taskIDs { id, err := strconv.ParseInt(taskID, 10, 64) if err != nil { task, err := h.repos.Tasks.GetByUUID(ctx, taskID) if err != nil { return fmt.Errorf("invalid task ID %s: %w", taskID, err) } id = task.ID } ids = append(ids, id) } updates := &models.Task{ Status: status, Priority: priority, Project: project, Context: context, } if len(tags) > 0 { if addTags { for _, id := range ids { task, err := h.repos.Tasks.Get(ctx, id) if err != nil { return fmt.Errorf("failed to get task: %w", err) } for _, tag := range tags { if !slices.Contains(task.Tags, tag) { task.Tags = append(task.Tags, tag) } } if err := h.repos.Tasks.Update(ctx, task); err != nil { return fmt.Errorf("failed to update task: %w", err) } } } else if removeTags { for _, id := range ids { task, err := h.repos.Tasks.Get(ctx, id) if err != nil { return fmt.Errorf("failed to get task: %w", err) } for _, tag := range tags { task.Tags = removeString(task.Tags, tag) } if err := h.repos.Tasks.Update(ctx, task); err != nil { return fmt.Errorf("failed to update task: %w", err) } } } else { updates.Tags = tags } } if err := h.repos.Tasks.BulkUpdate(ctx, ids, updates); err != nil { return fmt.Errorf("bulk update failed: %w", err) } fmt.Printf("Updated %d task(s)\n", len(ids)) if status != "" { fmt.Printf("Status: %s\n", status) } if priority != "" { fmt.Printf("Priority: %s\n", priority) } if project != "" { fmt.Printf("Project: %s\n", project) } if context != "" { fmt.Printf("Context: %s\n", context) } if len(tags) > 0 { if addTags { fmt.Printf("Added tags: %s\n", strings.Join(tags, ", ")) } else if removeTags { fmt.Printf("Removed tags: %s\n", strings.Join(tags, ", ")) } else { fmt.Printf("Set tags: %s\n", strings.Join(tags, ", ")) } } return nil } // UndoTask reverts a task to its previous state func (h *TaskHandler) UndoTask(ctx context.Context, taskID string) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } err = h.repos.Tasks.UndoLastChange(ctx, task.ID) if err != nil { return fmt.Errorf("failed to undo task: %w", err) } fmt.Printf("Undid last change to task (ID: %d)\n", task.ID) return nil } // ShowHistory displays the change history for a task func (h *TaskHandler) ShowHistory(ctx context.Context, taskID string, limit int) error { var task *models.Task var err error if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { task, err = h.repos.Tasks.Get(ctx, id) } else { task, err = h.repos.Tasks.GetByUUID(ctx, taskID) } if err != nil { return fmt.Errorf("failed to find task: %w", err) } history, err := h.repos.Tasks.GetHistory(ctx, task.ID, limit) if err != nil { return fmt.Errorf("failed to get history: %w", err) } if len(history) == 0 { fmt.Printf("No history found for task (ID: %d): %s\n", task.ID, task.Description) return nil } fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) fmt.Printf("History (%d changes):\n\n", len(history)) for i, h := range history { fmt.Printf("%d. [%s] %s at %s\n", i+1, h.Operation, task.Description, h.CreatedAt.Format("2006-01-02 15:04:05")) } return nil }