From 5332bcde7ca6260aaec509ff0c0ec491aff73fd3 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 10 Nov 2025 18:22:54 -0600 Subject: [PATCH] feat(tasks): added reporting & additional task views * extended task model for scoring, & dependencies --- cmd/task_commands.go | 99 +++++- cmd/task_flags.go | 5 + internal/docs/ROADMAP.md | 25 +- internal/handlers/task_helpers.go | 8 +- internal/handlers/tasks.go | 283 +++++++++++++++++- internal/handlers/tasks_test.go | 24 +- internal/models/models.go | 103 ++++++- internal/repo/queries.go | 8 +- internal/repo/task_repository.go | 12 +- .../0009_add_wait_scheduled_to_tasks_down.sql | 2 + .../0009_add_wait_scheduled_to_tasks_up.sql | 2 + 11 files changed, 524 insertions(+), 47 deletions(-) create mode 100644 internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_down.sql create mode 100644 internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_up.sql diff --git a/cmd/task_commands.go b/cmd/task_commands.go index 02b63f8..b7bdf26 100644 --- a/cmd/task_commands.go +++ b/cmd/task_commands.go @@ -33,6 +33,7 @@ time tracking. Tasks can be filtered by status, priority, project, or context.`, &cobra.Group{ID: "task-ops", Title: "Basic Operations"}, &cobra.Group{ID: "task-meta", Title: "Metadata"}, &cobra.Group{ID: "task-tracking", Title: "Tracking"}, + &cobra.Group{ID: "task-reports", Title: "Reports & Views"}, ) for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ @@ -59,6 +60,14 @@ time tracking. Tasks can be filtered by status, priority, project, or context.`, root.AddCommand(cmd) } + for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ + nextActionsCmd, reportCompletedCmd, reportWaitingCmd, reportBlockedCmd, calendarCmd, + } { + cmd := init(c.handler) + cmd.GroupID = "task-reports" + root.AddCommand(cmd) + } + return root } @@ -84,6 +93,8 @@ Examples: project, _ := c.Flags().GetString("project") context, _ := c.Flags().GetString("context") due, _ := c.Flags().GetString("due") + wait, _ := c.Flags().GetString("wait") + scheduled, _ := c.Flags().GetString("scheduled") recur, _ := c.Flags().GetString("recur") until, _ := c.Flags().GetString("until") parent, _ := c.Flags().GetString("parent") @@ -91,11 +102,13 @@ Examples: tags, _ := c.Flags().GetStringSlice("tags") defer h.Close() - return h.Create(c.Context(), description, priority, project, context, due, recur, until, parent, dependsOn, tags) + // TODO: Make a CreateTask struct + return h.Create(c.Context(), description, priority, project, context, due, wait, scheduled, recur, until, parent, dependsOn, tags) }, } addCommonTaskFlags(cmd) addDueDateFlag(cmd) + addWaitScheduledFlags(cmd) addRecurrenceFlags(cmd) addParentFlag(cmd) addDependencyFlags(cmd) @@ -120,9 +133,11 @@ Use --all to show all tasks, otherwise only pending tasks are shown.`, priority, _ := c.Flags().GetString("priority") project, _ := c.Flags().GetString("project") context, _ := c.Flags().GetString("context") + sortBy, _ := c.Flags().GetString("sort") defer h.Close() - return h.List(c.Context(), static, showAll, status, priority, project, context) + // TODO: TaskFilter struct + return h.List(c.Context(), static, showAll, status, priority, project, context, sortBy) }, } cmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)") @@ -132,6 +147,7 @@ Use --all to show all tasks, otherwise only pending tasks are shown.`, cmd.Flags().String("priority", "", "Filter by priority") cmd.Flags().String("project", "", "Filter by project") cmd.Flags().String("context", "", "Filter by context") + cmd.Flags().String("sort", "", "Sort by (urgency)") return cmd } @@ -451,6 +467,85 @@ configured.`, return root } +func nextActionsCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "next", + Short: "Show next actions (actionable tasks sorted by urgency)", + Aliases: []string{"na"}, + Long: `Display actionable tasks sorted by urgency score. + +Shows tasks that can be worked on now (not waiting, not blocked, not completed), +ordered by their computed urgency based on priority, due date, age, and other factors.`, + RunE: func(c *cobra.Command, args []string) error { + limit, _ := c.Flags().GetInt("limit") + defer h.Close() + return h.NextActions(c.Context(), limit) + }, + } + cmd.Flags().IntP("limit", "n", 10, "Limit number of tasks shown") + return cmd +} + +func reportCompletedCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "completed", + Short: "Show completed tasks", + Long: "Display tasks that have been completed, sorted by completion date.", + RunE: func(c *cobra.Command, args []string) error { + limit, _ := c.Flags().GetInt("limit") + defer h.Close() + return h.ReportCompleted(c.Context(), limit) + }, + } + cmd.Flags().IntP("limit", "n", 20, "Limit number of tasks shown") + return cmd +} + +func reportWaitingCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "waiting", + Short: "Show waiting tasks", + Long: "Display tasks that are waiting for a specific date before becoming actionable.", + RunE: func(c *cobra.Command, args []string) error { + defer h.Close() + return h.ReportWaiting(c.Context()) + }, + } + return cmd +} + +func reportBlockedCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "blocked", + Short: "Show blocked tasks", + Long: "Display tasks that are blocked by dependencies on other tasks.", + RunE: func(c *cobra.Command, args []string) error { + defer h.Close() + return h.ReportBlocked(c.Context()) + }, + } + return cmd +} + +func calendarCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "calendar", + Short: "Show tasks in calendar view", + Aliases: []string{"cal"}, + Long: `Display tasks with due dates in a calendar format. + +Shows tasks organized by week and day, making it easy to see upcoming deadlines +and plan your work schedule.`, + RunE: func(c *cobra.Command, args []string) error { + weeks, _ := c.Flags().GetInt("weeks") + defer h.Close() + return h.Calendar(c.Context(), weeks) + }, + } + cmd.Flags().IntP("weeks", "w", 4, "Number of weeks to show") + return cmd +} + func taskDependCmd(h *handlers.TaskHandler) *cobra.Command { root := &cobra.Command{ Use: "depend", diff --git a/cmd/task_flags.go b/cmd/task_flags.go index 875b4c4..c42ba86 100644 --- a/cmd/task_flags.go +++ b/cmd/task_flags.go @@ -31,3 +31,8 @@ func addOutputFlags(cmd *cobra.Command) { func addDueDateFlag(cmd *cobra.Command) { cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") } + +func addWaitScheduledFlags(cmd *cobra.Command) { + cmd.Flags().StringP("wait", "w", "", "Task not actionable until date (YYYY-MM-DD)") + cmd.Flags().StringP("scheduled", "s", "", "Task scheduled to start on date (YYYY-MM-DD)") +} diff --git a/internal/docs/ROADMAP.md b/internal/docs/ROADMAP.md index e4b751a..8256d5f 100644 --- a/internal/docs/ROADMAP.md +++ b/internal/docs/ROADMAP.md @@ -126,20 +126,20 @@ Planned functionality for a complete baseline release. ### Tasks -- [ ] Model - - [ ] Dependencies - - [ ] Recurrence (`recur`, `until`, templates) - - [ ] Wait/scheduled dates - - [ ] Urgency scoring +- [x] Model + - [x] Dependencies + - [x] Recurrence (`recur`, `until`, templates) + - [x] Wait/scheduled dates + - [x] Urgency scoring - [ ] Operations - [ ] `annotate` - [ ] Bulk edit and undo/history - [ ] `$EDITOR` integration -- [ ] Reports and Views - - [ ] Next actions - - [ ] Completed/waiting/blocked reports - - [ ] Calendar view - - [ ] Sorting and urgency-based views +- [x] Reports and Views + - [x] Next actions + - [x] Completed/waiting/blocked reports + - [x] Calendar view + - [x] Sorting and urgency-based views - [ ] Queries and Filters - [ ] Rich query language - [ ] Saved filters and aliases @@ -442,8 +442,9 @@ SQLite persistence, CI with GitHub Actions and Codecov, TUIs with Charm stack, i | Tasks | Time tracking | Complete | | Tasks | Dependencies | Complete | | Tasks | Recurrence | Complete | -| Tasks | Wait/scheduled | Planned | -| Tasks | Urgency scoring | Planned | +| Tasks | Wait/scheduled | Complete | +| Tasks | Urgency scoring | Complete | +| Tasks | Reports and views | Complete | | Notes | CRUD | Complete | | Notes | Search/tagging | Planned | | Publications | AT Protocol sync | Complete | diff --git a/internal/handlers/task_helpers.go b/internal/handlers/task_helpers.go index b2671c7..a76053b 100644 --- a/internal/handlers/task_helpers.go +++ b/internal/handlers/task_helpers.go @@ -18,6 +18,8 @@ type ParsedTaskData struct { Context string Tags []string Due string + Wait string + Scheduled string Recur string Until string ParentUUID string @@ -25,7 +27,7 @@ type ParsedTaskData struct { } // parseDescription extracts inline metadata from description text -// Supports: +project @context #tag due:YYYY-MM-DD recur:RULE until:DATE parent:UUID depends:UUID1,UUID2 +// Supports: +project @context #tag due:YYYY-MM-DD wait:YYYY-MM-DD scheduled:YYYY-MM-DD recur:RULE until:DATE parent:UUID depends:UUID1,UUID2 func parseDescription(text string) *ParsedTaskData { parsed := &ParsedTaskData{Tags: []string{}, DependsOn: []string{}} words := strings.Fields(text) @@ -41,6 +43,10 @@ func parseDescription(text string) *ParsedTaskData { parsed.Tags = append(parsed.Tags, strings.TrimPrefix(word, "#")) case strings.HasPrefix(word, "due:"): parsed.Due = strings.TrimPrefix(word, "due:") + case strings.HasPrefix(word, "wait:"): + parsed.Wait = strings.TrimPrefix(word, "wait:") + case strings.HasPrefix(word, "scheduled:"): + parsed.Scheduled = strings.TrimPrefix(word, "scheduled:") case strings.HasPrefix(word, "recur:"): parsed.Recur = strings.TrimPrefix(word, "recur:") case strings.HasPrefix(word, "until:"): diff --git a/internal/handlers/tasks.go b/internal/handlers/tasks.go index 1275b2c..9e77c9f 100644 --- a/internal/handlers/tasks.go +++ b/internal/handlers/tasks.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "slices" + "sort" "strconv" "strings" "time" @@ -51,7 +52,7 @@ func (h *TaskHandler) Close() error { } // Create creates a new task -func (h *TaskHandler) Create(ctx context.Context, description, priority, project, context, due, recur, until, parentUUID, dependsOn string, tags []string) error { +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") } @@ -67,6 +68,12 @@ func (h *TaskHandler) Create(ctx context.Context, description, priority, project if due != "" { parsed.Due = due } + if wait != "" { + parsed.Wait = wait + } + if scheduled != "" { + parsed.Scheduled = scheduled + } if recur != "" { parsed.Recur = recur } @@ -103,6 +110,22 @@ func (h *TaskHandler) Create(ctx context.Context, description, priority, project } } + 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 @@ -154,15 +177,15 @@ func (h *TaskHandler) Create(ctx context.Context, description, priority, project } // List lists all tasks with optional filtering -func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project, context string) error { +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) + 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 string) error { +func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project, context, sortBy string) error { opts := repo.TaskListOptions{ Status: status, Priority: priority, @@ -179,13 +202,29 @@ func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, 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):\n\n", len(tasks)) + 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) } @@ -958,3 +997,237 @@ func (h *TaskHandler) BlockedByDep(ctx context.Context, taskID string) error { 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 +} diff --git a/internal/handlers/tasks_test.go b/internal/handlers/tasks_test.go index 7c6da56..37f2294 100644 --- a/internal/handlers/tasks_test.go +++ b/internal/handlers/tasks_test.go @@ -77,7 +77,7 @@ func TestTaskHandler(t *testing.T) { t.Run("creates task successfully", func(t *testing.T) { desc := "Buy groceries and cook dinner" - err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", "", "", []string{}) shared.AssertNoError(t, err, "CreateTask should succeed") tasks, err := handler.repos.Tasks.GetPending(ctx) @@ -104,7 +104,7 @@ func TestTaskHandler(t *testing.T) { t.Run("fails with empty description", func(t *testing.T) { desc := "" - err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", "", "", []string{}) shared.AssertError(t, err, "Expected error for empty description") shared.AssertContains(t, err.Error(), "task description required", "Error message should mention required description") }) @@ -116,7 +116,7 @@ func TestTaskHandler(t *testing.T) { due := "2024-12-31" tags := []string{"urgent", "work"} - err := handler.Create(ctx, description, priority, project, "test-context", due, "", "", "", "", tags) + err := handler.Create(ctx, description, priority, project, "test-context", due, "", "", "", "", "", "", tags) if err != nil { t.Errorf("CreateTask with flags failed: %v", err) } @@ -171,7 +171,7 @@ func TestTaskHandler(t *testing.T) { desc := "Task with invalid date" invalidDue := "invalid-date" - err := handler.Create(ctx, desc, "", "", "", invalidDue, "", "", "", "", []string{}) + err := handler.Create(ctx, desc, "", "", "", invalidDue, "", "", "", "", "", "", []string{}) if err == nil { t.Error("Expected error for invalid due date format") } @@ -185,7 +185,7 @@ func TestTaskHandler(t *testing.T) { ctx, cancel := context.WithCancel(ctx) cancel() - err := handler.Create(ctx, "Test task", "", "", "", "", "", "", "", "", []string{}) + err := handler.Create(ctx, "Test task", "", "", "", "", "", "", "", "", "", "", []string{}) if err == nil { t.Error("Expected error when repository Create fails") } @@ -229,35 +229,35 @@ func TestTaskHandler(t *testing.T) { } t.Run("lists pending tasks by default (static mode)", func(t *testing.T) { - err := handler.List(ctx, true, false, "", "", "", "") + err := handler.List(ctx, true, false, "", "", "", "", "") if err != nil { t.Errorf("ListTasks failed: %v", err) } }) t.Run("filters by status (static mode)", func(t *testing.T) { - err := handler.List(ctx, true, false, "completed", "", "", "") + err := handler.List(ctx, true, false, "completed", "", "", "", "") if err != nil { t.Errorf("ListTasks with status filter failed: %v", err) } }) t.Run("filters by priority (static mode)", func(t *testing.T) { - err := handler.List(ctx, true, false, "", "A", "", "") + err := handler.List(ctx, true, false, "", "A", "", "", "") if err != nil { t.Errorf("ListTasks with priority filter failed: %v", err) } }) t.Run("filters by project (static mode)", func(t *testing.T) { - err := handler.List(ctx, true, false, "", "", "work", "") + err := handler.List(ctx, true, false, "", "", "work", "", "") if err != nil { t.Errorf("ListTasks with project filter failed: %v", err) } }) t.Run("show all tasks (static mode)", func(t *testing.T) { - err := handler.List(ctx, true, true, "", "", "", "") + err := handler.List(ctx, true, true, "", "", "", "", "") if err != nil { t.Errorf("ListTasks with show all failed: %v", err) } @@ -1479,12 +1479,12 @@ func TestTaskHandler(t *testing.T) { } defer handler.Close() - err = handler.Create(ctx, "Test Task 1", "high", "test-project", "test-context", "", "", "", "", "", []string{"tag1"}) + err = handler.Create(ctx, "Test Task 1", "high", "test-project", "test-context", "", "", "", "", "", "", "", []string{"tag1"}) if err != nil { t.Fatalf("Failed to create test task: %v", err) } - err = handler.Create(ctx, "Test Task 2", "medium", "test-project", "test-context", "", "", "", "", "", []string{"tag2"}) + err = handler.Create(ctx, "Test Task 2", "medium", "test-project", "test-context", "", "", "", "", "", "", "", []string{"tag2"}) if err != nil { t.Fatalf("Failed to create test task: %v", err) } diff --git a/internal/models/models.go b/internal/models/models.go index 062b5e2..46d1b3b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -112,6 +112,8 @@ type Task struct { Context string `json:"context,omitempty"` Tags []string `json:"tags,omitempty"` Due *time.Time `json:"due,omitempty"` + Wait *time.Time `json:"wait,omitempty"` // Task is not actionable until this date + Scheduled *time.Time `json:"scheduled,omitempty"` // Task is scheduled to start on this date Entry time.Time `json:"entry"` Modified time.Time `json:"modified"` End *time.Time `json:"end,omitempty"` // Completion time @@ -342,6 +344,29 @@ func (t *Task) IsOverdue(now time.Time) bool { // HasDueDate returns true if the task has a due date set. func (t *Task) HasDueDate() bool { return t.Due != nil } +// IsWaiting returns true if the task has a wait date and it hasn't passed yet. +func (t *Task) IsWaiting(now time.Time) bool { + return t.Wait != nil && now.Before(*t.Wait) +} + +// HasWaitDate returns true if the task has a wait date set. +func (t *Task) HasWaitDate() bool { return t.Wait != nil } + +// IsScheduled returns true if the task has a scheduled date. +func (t *Task) IsScheduled() bool { return t.Scheduled != nil } + +// IsActionable returns true if the task can be worked on now. +// A task is actionable if it's not waiting, not blocked, and not completed. +func (t *Task) IsActionable(now time.Time) bool { + if t.IsCompleted() || t.IsDone() || t.IsAbandoned() || t.IsBlocked() { + return false + } + if t.IsWaiting(now) { + return false + } + return true +} + // IsRecurring returns true if the task has recurrence defined. func (t *Task) IsRecurring() bool { return t.Recur != "" } @@ -358,19 +383,87 @@ func (t *Task) Blocks(other *Task) bool { return slices.Contains(other.DependsOn, t.UUID) } -// Urgency computes a score based on priority, due date, and tags. -// This can be expanded later with weights. +// Urgency computes a comprehensive score based on multiple factors. +// Higher score means more urgent. Score components: +// - Priority: 0-10 based on priority weight +// - Due date: 0-12 based on proximity (overdue gets highest) +// - Scheduled: 0-4 if scheduled soon +// - Age: 0-2 for old tasks +// - Tags: 0.5 per tag (capped at 2.0) +// - Waiting: -5.0 if not yet actionable +// - Blocked: -3.0 if has incomplete dependencies func (t *Task) Urgency(now time.Time) float64 { + if !t.IsActionable(now) { + if t.IsWaiting(now) { + return -5.0 + } + if t.IsBlocked() { + return -3.0 + } + return -10.0 + } + score := 0.0 - if t.Priority != "" { - score += 1.0 + + if t.HasPriority() { + weight := t.GetPriorityWeight() + if weight >= 20 { + score += float64(weight-15) / 2.0 + } else if weight > 0 { + score += float64(weight) * 2.0 + } + } + + if t.HasDueDate() { + daysUntilDue := t.Due.Sub(now).Hours() / 24.0 + if daysUntilDue < 0 { + overdueDays := -daysUntilDue + score += 12.0 + min(overdueDays*0.5, 3.0) + } else if daysUntilDue <= 1 { + score += 10.0 + } else if daysUntilDue <= 3 { + score += 8.0 + } else if daysUntilDue <= 7 { + score += 6.0 + } else if daysUntilDue <= 14 { + score += 4.0 + } else if daysUntilDue <= 30 { + score += 2.0 + } + } + + if t.IsScheduled() { + daysUntilScheduled := t.Scheduled.Sub(now).Hours() / 24.0 + if daysUntilScheduled <= 0 { + score += 4.0 + } else if daysUntilScheduled <= 1 { + score += 3.0 + } else if daysUntilScheduled <= 3 { + score += 2.0 + } else if daysUntilScheduled <= 7 { + score += 1.0 + } } - if t.IsOverdue(now) { + + age := now.Sub(t.Entry).Hours() / 24.0 + if age > 90 { score += 2.0 + } else if age > 30 { + score += 1.5 + } else if age > 14 { + score += 1.0 + } else if age > 7 { + score += 0.5 } + if len(t.Tags) > 0 { + score += min(float64(len(t.Tags))*0.5, 2.0) + } + + if t.Project != "" { score += 0.5 } + return score } diff --git a/internal/repo/queries.go b/internal/repo/queries.go index 8c7c406..b3aff24 100644 --- a/internal/repo/queries.go +++ b/internal/repo/queries.go @@ -20,20 +20,20 @@ const ( ) const ( - taskColumns = "id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations, recur, until, parent_uuid" + taskColumns = "id, uuid, description, status, priority, project, context, tags, due, wait, scheduled, entry, modified, end, start, annotations, recur, until, parent_uuid" queryTaskByID = "SELECT " + taskColumns + " FROM tasks WHERE id = ?" queryTaskByUUID = "SELECT " + taskColumns + " FROM tasks WHERE uuid = ?" queryTaskInsert = ` INSERT INTO tasks ( uuid, description, status, priority, project, context, - tags, due, entry, modified, end, start, annotations, + tags, due, wait, scheduled, entry, modified, end, start, annotations, recur, until, parent_uuid ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` queryTaskUpdate = ` UPDATE tasks SET uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, - tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?, + tags = ?, due = ?, wait = ?, scheduled = ?, modified = ?, end = ?, start = ?, annotations = ?, recur = ?, until = ?, parent_uuid = ? WHERE id = ?` queryTaskDelete = "DELETE FROM tasks WHERE id = ?" diff --git a/internal/repo/task_repository.go b/internal/repo/task_repository.go index 845bcdf..a7e60a9 100644 --- a/internal/repo/task_repository.go +++ b/internal/repo/task_repository.go @@ -71,7 +71,7 @@ func (r *TaskRepository) scanTask(s scanner) (*models.Task, error) { if err := s.Scan( &task.ID, &task.UUID, &task.Description, &task.Status, &priority, &project, &context, &tags, - &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, + &task.Due, &task.Wait, &task.Scheduled, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, &task.Recur, &task.Until, &parentUUID, ); err != nil { return nil, err @@ -160,7 +160,7 @@ func (r *TaskRepository) Create(ctx context.Context, task *models.Task) (int64, result, err := r.db.ExecContext(ctx, queryTaskInsert, task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, - tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations, + tags, task.Due, task.Wait, task.Scheduled, task.Entry, task.Modified, task.End, task.Start, annotations, task.Recur, task.Until, task.ParentUUID, ) if err != nil { @@ -213,7 +213,7 @@ func (r *TaskRepository) Update(ctx context.Context, task *models.Task) error { if _, err = r.db.ExecContext(ctx, queryTaskUpdate, task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, - tags, task.Due, task.Modified, task.End, task.Start, annotations, + tags, task.Due, task.Wait, task.Scheduled, task.Modified, task.End, task.Start, annotations, task.Recur, task.Until, task.ParentUUID, task.ID, ); err != nil { @@ -518,7 +518,7 @@ func (r *TaskRepository) GetContexts(ctx context.Context) ([]ContextSummary, err func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) { query := ` SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, - t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, + t.tags, t.due, t.wait, t.scheduled, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid FROM tasks t, json_each(t.tags) WHERE t.tags != '' AND t.tags IS NOT NULL AND json_each.value = ? @@ -684,7 +684,7 @@ func (r *TaskRepository) PopulateDependencies(ctx context.Context, task *models. func (r *TaskRepository) GetDependents(ctx context.Context, blockingUUID string) ([]*models.Task, error) { query := ` SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, - t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid + t.tags, t.due, t.wait, t.scheduled, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid FROM tasks t JOIN task_dependencies d ON t.uuid = d.task_uuid WHERE d.depends_on_uuid = ?` tasks, err := r.queryMany(ctx, query, blockingUUID) @@ -704,7 +704,7 @@ func (r *TaskRepository) GetDependents(ctx context.Context, blockingUUID string) func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID string) ([]*models.Task, error) { query := ` SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, - t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid + t.tags, t.due, t.wait, t.scheduled, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid FROM tasks t JOIN task_dependencies d ON t.uuid = d.task_uuid WHERE d.depends_on_uuid = ?` diff --git a/internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_down.sql b/internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_down.sql new file mode 100644 index 0000000..a92f3c6 --- /dev/null +++ b/internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_down.sql @@ -0,0 +1,2 @@ +ALTER TABLE tasks DROP COLUMN wait; +ALTER TABLE tasks DROP COLUMN scheduled; diff --git a/internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_up.sql b/internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_up.sql new file mode 100644 index 0000000..6ddc7f3 --- /dev/null +++ b/internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_up.sql @@ -0,0 +1,2 @@ +ALTER TABLE tasks ADD COLUMN wait DATETIME; +ALTER TABLE tasks ADD COLUMN scheduled DATETIME; -- 2.51.2