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

feat(tasks): added reporting & additional task views

* extended task model for scoring, & dependencies

+524 -47
+97 -2
cmd/task_commands.go
··· 33 33 &cobra.Group{ID: "task-ops", Title: "Basic Operations"}, 34 34 &cobra.Group{ID: "task-meta", Title: "Metadata"}, 35 35 &cobra.Group{ID: "task-tracking", Title: "Tracking"}, 36 + &cobra.Group{ID: "task-reports", Title: "Reports & Views"}, 36 37 ) 37 38 38 39 for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ ··· 59 60 root.AddCommand(cmd) 60 61 } 61 62 63 + for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 64 + nextActionsCmd, reportCompletedCmd, reportWaitingCmd, reportBlockedCmd, calendarCmd, 65 + } { 66 + cmd := init(c.handler) 67 + cmd.GroupID = "task-reports" 68 + root.AddCommand(cmd) 69 + } 70 + 62 71 return root 63 72 } 64 73 ··· 84 93 project, _ := c.Flags().GetString("project") 85 94 context, _ := c.Flags().GetString("context") 86 95 due, _ := c.Flags().GetString("due") 96 + wait, _ := c.Flags().GetString("wait") 97 + scheduled, _ := c.Flags().GetString("scheduled") 87 98 recur, _ := c.Flags().GetString("recur") 88 99 until, _ := c.Flags().GetString("until") 89 100 parent, _ := c.Flags().GetString("parent") ··· 91 102 tags, _ := c.Flags().GetStringSlice("tags") 92 103 93 104 defer h.Close() 94 - return h.Create(c.Context(), description, priority, project, context, due, recur, until, parent, dependsOn, tags) 105 + // TODO: Make a CreateTask struct 106 + return h.Create(c.Context(), description, priority, project, context, due, wait, scheduled, recur, until, parent, dependsOn, tags) 95 107 }, 96 108 } 97 109 addCommonTaskFlags(cmd) 98 110 addDueDateFlag(cmd) 111 + addWaitScheduledFlags(cmd) 99 112 addRecurrenceFlags(cmd) 100 113 addParentFlag(cmd) 101 114 addDependencyFlags(cmd) ··· 120 133 priority, _ := c.Flags().GetString("priority") 121 134 project, _ := c.Flags().GetString("project") 122 135 context, _ := c.Flags().GetString("context") 136 + sortBy, _ := c.Flags().GetString("sort") 123 137 124 138 defer h.Close() 125 - return h.List(c.Context(), static, showAll, status, priority, project, context) 139 + // TODO: TaskFilter struct 140 + return h.List(c.Context(), static, showAll, status, priority, project, context, sortBy) 126 141 }, 127 142 } 128 143 cmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)") ··· 132 147 cmd.Flags().String("priority", "", "Filter by priority") 133 148 cmd.Flags().String("project", "", "Filter by project") 134 149 cmd.Flags().String("context", "", "Filter by context") 150 + cmd.Flags().String("sort", "", "Sort by (urgency)") 135 151 136 152 return cmd 137 153 } ··· 449 465 450 466 root.AddCommand(setCmd, clearCmd, showCmd) 451 467 return root 468 + } 469 + 470 + func nextActionsCmd(h *handlers.TaskHandler) *cobra.Command { 471 + cmd := &cobra.Command{ 472 + Use: "next", 473 + Short: "Show next actions (actionable tasks sorted by urgency)", 474 + Aliases: []string{"na"}, 475 + Long: `Display actionable tasks sorted by urgency score. 476 + 477 + Shows tasks that can be worked on now (not waiting, not blocked, not completed), 478 + ordered by their computed urgency based on priority, due date, age, and other factors.`, 479 + RunE: func(c *cobra.Command, args []string) error { 480 + limit, _ := c.Flags().GetInt("limit") 481 + defer h.Close() 482 + return h.NextActions(c.Context(), limit) 483 + }, 484 + } 485 + cmd.Flags().IntP("limit", "n", 10, "Limit number of tasks shown") 486 + return cmd 487 + } 488 + 489 + func reportCompletedCmd(h *handlers.TaskHandler) *cobra.Command { 490 + cmd := &cobra.Command{ 491 + Use: "completed", 492 + Short: "Show completed tasks", 493 + Long: "Display tasks that have been completed, sorted by completion date.", 494 + RunE: func(c *cobra.Command, args []string) error { 495 + limit, _ := c.Flags().GetInt("limit") 496 + defer h.Close() 497 + return h.ReportCompleted(c.Context(), limit) 498 + }, 499 + } 500 + cmd.Flags().IntP("limit", "n", 20, "Limit number of tasks shown") 501 + return cmd 502 + } 503 + 504 + func reportWaitingCmd(h *handlers.TaskHandler) *cobra.Command { 505 + cmd := &cobra.Command{ 506 + Use: "waiting", 507 + Short: "Show waiting tasks", 508 + Long: "Display tasks that are waiting for a specific date before becoming actionable.", 509 + RunE: func(c *cobra.Command, args []string) error { 510 + defer h.Close() 511 + return h.ReportWaiting(c.Context()) 512 + }, 513 + } 514 + return cmd 515 + } 516 + 517 + func reportBlockedCmd(h *handlers.TaskHandler) *cobra.Command { 518 + cmd := &cobra.Command{ 519 + Use: "blocked", 520 + Short: "Show blocked tasks", 521 + Long: "Display tasks that are blocked by dependencies on other tasks.", 522 + RunE: func(c *cobra.Command, args []string) error { 523 + defer h.Close() 524 + return h.ReportBlocked(c.Context()) 525 + }, 526 + } 527 + return cmd 528 + } 529 + 530 + func calendarCmd(h *handlers.TaskHandler) *cobra.Command { 531 + cmd := &cobra.Command{ 532 + Use: "calendar", 533 + Short: "Show tasks in calendar view", 534 + Aliases: []string{"cal"}, 535 + Long: `Display tasks with due dates in a calendar format. 536 + 537 + Shows tasks organized by week and day, making it easy to see upcoming deadlines 538 + and plan your work schedule.`, 539 + RunE: func(c *cobra.Command, args []string) error { 540 + weeks, _ := c.Flags().GetInt("weeks") 541 + defer h.Close() 542 + return h.Calendar(c.Context(), weeks) 543 + }, 544 + } 545 + cmd.Flags().IntP("weeks", "w", 4, "Number of weeks to show") 546 + return cmd 452 547 } 453 548 454 549 func taskDependCmd(h *handlers.TaskHandler) *cobra.Command {
+5
cmd/task_flags.go
··· 31 31 func addDueDateFlag(cmd *cobra.Command) { 32 32 cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") 33 33 } 34 + 35 + func addWaitScheduledFlags(cmd *cobra.Command) { 36 + cmd.Flags().StringP("wait", "w", "", "Task not actionable until date (YYYY-MM-DD)") 37 + cmd.Flags().StringP("scheduled", "s", "", "Task scheduled to start on date (YYYY-MM-DD)") 38 + }
+13 -12
internal/docs/ROADMAP.md
··· 126 126 127 127 ### Tasks 128 128 129 - - [ ] Model 130 - - [ ] Dependencies 131 - - [ ] Recurrence (`recur`, `until`, templates) 132 - - [ ] Wait/scheduled dates 133 - - [ ] Urgency scoring 129 + - [x] Model 130 + - [x] Dependencies 131 + - [x] Recurrence (`recur`, `until`, templates) 132 + - [x] Wait/scheduled dates 133 + - [x] Urgency scoring 134 134 - [ ] Operations 135 135 - [ ] `annotate` 136 136 - [ ] Bulk edit and undo/history 137 137 - [ ] `$EDITOR` integration 138 - - [ ] Reports and Views 139 - - [ ] Next actions 140 - - [ ] Completed/waiting/blocked reports 141 - - [ ] Calendar view 142 - - [ ] Sorting and urgency-based views 138 + - [x] Reports and Views 139 + - [x] Next actions 140 + - [x] Completed/waiting/blocked reports 141 + - [x] Calendar view 142 + - [x] Sorting and urgency-based views 143 143 - [ ] Queries and Filters 144 144 - [ ] Rich query language 145 145 - [ ] Saved filters and aliases ··· 442 442 | Tasks | Time tracking | Complete | 443 443 | Tasks | Dependencies | Complete | 444 444 | Tasks | Recurrence | Complete | 445 - | Tasks | Wait/scheduled | Planned | 446 - | Tasks | Urgency scoring | Planned | 445 + | Tasks | Wait/scheduled | Complete | 446 + | Tasks | Urgency scoring | Complete | 447 + | Tasks | Reports and views | Complete | 447 448 | Notes | CRUD | Complete | 448 449 | Notes | Search/tagging | Planned | 449 450 | Publications | AT Protocol sync | Complete |
+7 -1
internal/handlers/task_helpers.go
··· 18 18 Context string 19 19 Tags []string 20 20 Due string 21 + Wait string 22 + Scheduled string 21 23 Recur string 22 24 Until string 23 25 ParentUUID string ··· 25 27 } 26 28 27 29 // parseDescription extracts inline metadata from description text 28 - // Supports: +project @context #tag due:YYYY-MM-DD recur:RULE until:DATE parent:UUID depends:UUID1,UUID2 30 + // 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 29 31 func parseDescription(text string) *ParsedTaskData { 30 32 parsed := &ParsedTaskData{Tags: []string{}, DependsOn: []string{}} 31 33 words := strings.Fields(text) ··· 41 43 parsed.Tags = append(parsed.Tags, strings.TrimPrefix(word, "#")) 42 44 case strings.HasPrefix(word, "due:"): 43 45 parsed.Due = strings.TrimPrefix(word, "due:") 46 + case strings.HasPrefix(word, "wait:"): 47 + parsed.Wait = strings.TrimPrefix(word, "wait:") 48 + case strings.HasPrefix(word, "scheduled:"): 49 + parsed.Scheduled = strings.TrimPrefix(word, "scheduled:") 44 50 case strings.HasPrefix(word, "recur:"): 45 51 parsed.Recur = strings.TrimPrefix(word, "recur:") 46 52 case strings.HasPrefix(word, "until:"):
+278 -5
internal/handlers/tasks.go
··· 6 6 "fmt" 7 7 "os" 8 8 "slices" 9 + "sort" 9 10 "strconv" 10 11 "strings" 11 12 "time" ··· 51 52 } 52 53 53 54 // Create creates a new task 54 - func (h *TaskHandler) Create(ctx context.Context, description, priority, project, context, due, recur, until, parentUUID, dependsOn string, tags []string) error { 55 + func (h *TaskHandler) Create(ctx context.Context, description, priority, project, context, due, wait, scheduled, recur, until, parentUUID, dependsOn string, tags []string) error { 55 56 if description == "" { 56 57 return fmt.Errorf("task description required") 57 58 } ··· 67 68 if due != "" { 68 69 parsed.Due = due 69 70 } 71 + if wait != "" { 72 + parsed.Wait = wait 73 + } 74 + if scheduled != "" { 75 + parsed.Scheduled = scheduled 76 + } 70 77 if recur != "" { 71 78 parsed.Recur = recur 72 79 } ··· 100 107 task.Due = &dueTime 101 108 } else { 102 109 return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) 110 + } 111 + } 112 + 113 + if parsed.Wait != "" { 114 + if waitTime, err := time.Parse("2006-01-02", parsed.Wait); err == nil { 115 + task.Wait = &waitTime 116 + } else { 117 + return fmt.Errorf("invalid wait date format, use YYYY-MM-DD: %w", err) 118 + } 119 + } 120 + 121 + if parsed.Scheduled != "" { 122 + if scheduledTime, err := time.Parse("2006-01-02", parsed.Scheduled); err == nil { 123 + task.Scheduled = &scheduledTime 124 + } else { 125 + return fmt.Errorf("invalid scheduled date format, use YYYY-MM-DD: %w", err) 103 126 } 104 127 } 105 128 ··· 154 177 } 155 178 156 179 // List lists all tasks with optional filtering 157 - func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project, context string) error { 180 + func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project, context, sortBy string) error { 158 181 if static { 159 - return h.listTasksStatic(ctx, showAll, status, priority, project, context) 182 + return h.listTasksStatic(ctx, showAll, status, priority, project, context, sortBy) 160 183 } 161 184 162 185 return h.listTasksInteractive(ctx, showAll, status, priority, project, context) 163 186 } 164 187 165 - func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project, context string) error { 188 + func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project, context, sortBy string) error { 166 189 opts := repo.TaskListOptions{ 167 190 Status: status, 168 191 Priority: priority, ··· 179 202 return fmt.Errorf("failed to list tasks: %w", err) 180 203 } 181 204 205 + if sortBy == "urgency" { 206 + now := time.Now() 207 + sort.Slice(tasks, func(i, j int) bool { 208 + return tasks[i].Urgency(now) > tasks[j].Urgency(now) 209 + }) 210 + } 211 + 182 212 if len(tasks) == 0 { 183 213 fmt.Printf("No tasks found matching criteria\n") 184 214 return nil 185 215 } 186 216 187 - fmt.Printf("Found %d task(s):\n\n", len(tasks)) 217 + fmt.Printf("Found %d task(s)", len(tasks)) 218 + if sortBy == "urgency" { 219 + fmt.Printf(" (sorted by urgency)") 220 + } 221 + fmt.Printf(":\n\n") 222 + 188 223 for _, task := range tasks { 224 + if sortBy == "urgency" { 225 + urgency := task.Urgency(time.Now()) 226 + fmt.Printf("[%.1f] ", urgency) 227 + } 189 228 printTask(task) 190 229 } 191 230 ··· 958 997 959 998 return nil 960 999 } 1000 + 1001 + // NextActions shows actionable tasks sorted by urgency 1002 + func (h *TaskHandler) NextActions(ctx context.Context, limit int) error { 1003 + opts := repo.TaskListOptions{ 1004 + SortBy: "urgency", 1005 + SortOrder: "desc", 1006 + } 1007 + 1008 + tasks, err := h.repos.Tasks.List(ctx, opts) 1009 + if err != nil { 1010 + return fmt.Errorf("failed to list tasks: %w", err) 1011 + } 1012 + 1013 + now := time.Now() 1014 + var actionable []*models.Task 1015 + for _, task := range tasks { 1016 + if task.IsActionable(now) { 1017 + actionable = append(actionable, task) 1018 + } 1019 + } 1020 + 1021 + sort.Slice(actionable, func(i, j int) bool { 1022 + return actionable[i].Urgency(now) > actionable[j].Urgency(now) 1023 + }) 1024 + 1025 + if limit > 0 && len(actionable) > limit { 1026 + actionable = actionable[:limit] 1027 + } 1028 + 1029 + if len(actionable) == 0 { 1030 + fmt.Println("No actionable tasks found") 1031 + return nil 1032 + } 1033 + 1034 + fmt.Printf("Next Actions (%d tasks, sorted by urgency):\n\n", len(actionable)) 1035 + for i, task := range actionable { 1036 + urgency := task.Urgency(now) 1037 + fmt.Printf("%d. [Urgency: %.1f] ", i+1, urgency) 1038 + printTask(task) 1039 + } 1040 + 1041 + return nil 1042 + } 1043 + 1044 + // ReportCompleted shows completed tasks 1045 + func (h *TaskHandler) ReportCompleted(ctx context.Context, limit int) error { 1046 + opts := repo.TaskListOptions{ 1047 + Status: "done", 1048 + SortBy: "modified", 1049 + SortOrder: "desc", 1050 + Limit: limit, 1051 + } 1052 + 1053 + tasks, err := h.repos.Tasks.List(ctx, opts) 1054 + if err != nil { 1055 + return fmt.Errorf("failed to list completed tasks: %w", err) 1056 + } 1057 + 1058 + if len(tasks) == 0 { 1059 + fmt.Println("No completed tasks found") 1060 + return nil 1061 + } 1062 + 1063 + fmt.Printf("Completed Tasks (%d):\n\n", len(tasks)) 1064 + for _, task := range tasks { 1065 + fmt.Printf(" ") 1066 + printTask(task) 1067 + if task.End != nil { 1068 + fmt.Printf(" Completed: %s\n", task.End.Format("2006-01-02 15:04")) 1069 + } 1070 + } 1071 + 1072 + return nil 1073 + } 1074 + 1075 + // ReportWaiting shows tasks that are waiting 1076 + func (h *TaskHandler) ReportWaiting(ctx context.Context) error { 1077 + opts := repo.TaskListOptions{ 1078 + SortBy: "wait", 1079 + SortOrder: "asc", 1080 + } 1081 + 1082 + tasks, err := h.repos.Tasks.List(ctx, opts) 1083 + if err != nil { 1084 + return fmt.Errorf("failed to list tasks: %w", err) 1085 + } 1086 + 1087 + now := time.Now() 1088 + var waiting []*models.Task 1089 + for _, task := range tasks { 1090 + if task.IsWaiting(now) { 1091 + waiting = append(waiting, task) 1092 + } 1093 + } 1094 + 1095 + if len(waiting) == 0 { 1096 + fmt.Println("No waiting tasks found") 1097 + return nil 1098 + } 1099 + 1100 + fmt.Printf("Waiting Tasks (%d):\n\n", len(waiting)) 1101 + for _, task := range waiting { 1102 + fmt.Printf(" ") 1103 + printTask(task) 1104 + if task.Wait != nil { 1105 + daysUntil := int(task.Wait.Sub(now).Hours() / 24) 1106 + fmt.Printf(" Wait until: %s (%d days)\n", task.Wait.Format("2006-01-02"), daysUntil) 1107 + } 1108 + } 1109 + 1110 + return nil 1111 + } 1112 + 1113 + // ReportBlocked shows blocked tasks 1114 + func (h *TaskHandler) ReportBlocked(ctx context.Context) error { 1115 + opts := repo.TaskListOptions{ 1116 + Status: "blocked", 1117 + } 1118 + 1119 + tasks, err := h.repos.Tasks.List(ctx, opts) 1120 + if err != nil { 1121 + return fmt.Errorf("failed to list blocked tasks: %w", err) 1122 + } 1123 + 1124 + if len(tasks) == 0 { 1125 + fmt.Println("No blocked tasks found") 1126 + return nil 1127 + } 1128 + 1129 + fmt.Printf("Blocked Tasks (%d):\n\n", len(tasks)) 1130 + for _, task := range tasks { 1131 + fmt.Printf(" ") 1132 + printTask(task) 1133 + 1134 + if len(task.DependsOn) > 0 { 1135 + fmt.Printf(" Depends on %d task(s)\n", len(task.DependsOn)) 1136 + } 1137 + } 1138 + 1139 + return nil 1140 + } 1141 + 1142 + // Calendar shows tasks by due date in a calendar-like view 1143 + func (h *TaskHandler) Calendar(ctx context.Context, weeks int) error { 1144 + if weeks <= 0 { 1145 + weeks = 4 1146 + } 1147 + 1148 + now := time.Now() 1149 + startDate := now.Truncate(24 * time.Hour) 1150 + endDate := startDate.AddDate(0, 0, weeks*7) 1151 + 1152 + opts := repo.TaskListOptions{ 1153 + SortBy: "due", 1154 + SortOrder: "asc", 1155 + } 1156 + 1157 + tasks, err := h.repos.Tasks.List(ctx, opts) 1158 + if err != nil { 1159 + return fmt.Errorf("failed to list tasks: %w", err) 1160 + } 1161 + 1162 + tasksByDate := make(map[string][]*models.Task) 1163 + overdue := []*models.Task{} 1164 + 1165 + for _, task := range tasks { 1166 + if task.Due == nil || task.IsCompleted() || task.IsDone() { 1167 + continue 1168 + } 1169 + 1170 + dueDate := task.Due.Truncate(24 * time.Hour) 1171 + if dueDate.Before(startDate) { 1172 + overdue = append(overdue, task) 1173 + } else if dueDate.Before(endDate) { 1174 + dateKey := dueDate.Format("2006-01-02") 1175 + tasksByDate[dateKey] = append(tasksByDate[dateKey], task) 1176 + } 1177 + } 1178 + 1179 + fmt.Printf("Calendar View (Next %d weeks)\n", weeks) 1180 + fmt.Printf("Today: %s\n\n", now.Format("Monday, January 2, 2006")) 1181 + 1182 + if len(overdue) > 0 { 1183 + fmt.Printf("OVERDUE (%d tasks):\n", len(overdue)) 1184 + for _, task := range overdue { 1185 + daysOverdue := int(now.Sub(*task.Due).Hours() / 24) 1186 + fmt.Printf(" [%d days overdue] ", daysOverdue) 1187 + printTask(task) 1188 + } 1189 + fmt.Println() 1190 + } 1191 + 1192 + currentDate := startDate 1193 + for currentDate.Before(endDate) { 1194 + weekStart := currentDate 1195 + weekEnd := currentDate.AddDate(0, 0, 6) 1196 + 1197 + weekTasks := 0 1198 + for d := weekStart; !d.After(weekEnd); d = d.AddDate(0, 0, 1) { 1199 + dateKey := d.Format("2006-01-02") 1200 + weekTasks += len(tasksByDate[dateKey]) 1201 + } 1202 + 1203 + if weekTasks > 0 { 1204 + fmt.Printf("Week of %s (%d tasks):\n", weekStart.Format("Jan 2"), weekTasks) 1205 + 1206 + for d := weekStart; !d.After(weekEnd); d = d.AddDate(0, 0, 1) { 1207 + dateKey := d.Format("2006-01-02") 1208 + dayTasks := tasksByDate[dateKey] 1209 + 1210 + if len(dayTasks) > 0 { 1211 + dayName := d.Format("Monday, Jan 2") 1212 + if d.Format("2006-01-02") == now.Format("2006-01-02") { 1213 + dayName += " (TODAY)" 1214 + } 1215 + fmt.Printf(" %s:\n", dayName) 1216 + for _, task := range dayTasks { 1217 + fmt.Printf(" ") 1218 + printTask(task) 1219 + } 1220 + } 1221 + } 1222 + fmt.Println() 1223 + } 1224 + 1225 + currentDate = currentDate.AddDate(0, 0, 7) 1226 + } 1227 + 1228 + if len(overdue) == 0 && len(tasksByDate) == 0 { 1229 + fmt.Println("No tasks with due dates in the next", weeks, "weeks") 1230 + } 1231 + 1232 + return nil 1233 + }
+12 -12
internal/handlers/tasks_test.go
··· 77 77 78 78 t.Run("creates task successfully", func(t *testing.T) { 79 79 desc := "Buy groceries and cook dinner" 80 - err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) 80 + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", "", "", []string{}) 81 81 shared.AssertNoError(t, err, "CreateTask should succeed") 82 82 83 83 tasks, err := handler.repos.Tasks.GetPending(ctx) ··· 104 104 105 105 t.Run("fails with empty description", func(t *testing.T) { 106 106 desc := "" 107 - err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) 107 + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", "", "", []string{}) 108 108 shared.AssertError(t, err, "Expected error for empty description") 109 109 shared.AssertContains(t, err.Error(), "task description required", "Error message should mention required description") 110 110 }) ··· 116 116 due := "2024-12-31" 117 117 tags := []string{"urgent", "work"} 118 118 119 - err := handler.Create(ctx, description, priority, project, "test-context", due, "", "", "", "", tags) 119 + err := handler.Create(ctx, description, priority, project, "test-context", due, "", "", "", "", "", "", tags) 120 120 if err != nil { 121 121 t.Errorf("CreateTask with flags failed: %v", err) 122 122 } ··· 171 171 desc := "Task with invalid date" 172 172 invalidDue := "invalid-date" 173 173 174 - err := handler.Create(ctx, desc, "", "", "", invalidDue, "", "", "", "", []string{}) 174 + err := handler.Create(ctx, desc, "", "", "", invalidDue, "", "", "", "", "", "", []string{}) 175 175 if err == nil { 176 176 t.Error("Expected error for invalid due date format") 177 177 } ··· 185 185 ctx, cancel := context.WithCancel(ctx) 186 186 cancel() 187 187 188 - err := handler.Create(ctx, "Test task", "", "", "", "", "", "", "", "", []string{}) 188 + err := handler.Create(ctx, "Test task", "", "", "", "", "", "", "", "", "", "", []string{}) 189 189 if err == nil { 190 190 t.Error("Expected error when repository Create fails") 191 191 } ··· 229 229 } 230 230 231 231 t.Run("lists pending tasks by default (static mode)", func(t *testing.T) { 232 - err := handler.List(ctx, true, false, "", "", "", "") 232 + err := handler.List(ctx, true, false, "", "", "", "", "") 233 233 if err != nil { 234 234 t.Errorf("ListTasks failed: %v", err) 235 235 } 236 236 }) 237 237 238 238 t.Run("filters by status (static mode)", func(t *testing.T) { 239 - err := handler.List(ctx, true, false, "completed", "", "", "") 239 + err := handler.List(ctx, true, false, "completed", "", "", "", "") 240 240 if err != nil { 241 241 t.Errorf("ListTasks with status filter failed: %v", err) 242 242 } 243 243 }) 244 244 245 245 t.Run("filters by priority (static mode)", func(t *testing.T) { 246 - err := handler.List(ctx, true, false, "", "A", "", "") 246 + err := handler.List(ctx, true, false, "", "A", "", "", "") 247 247 if err != nil { 248 248 t.Errorf("ListTasks with priority filter failed: %v", err) 249 249 } 250 250 }) 251 251 252 252 t.Run("filters by project (static mode)", func(t *testing.T) { 253 - err := handler.List(ctx, true, false, "", "", "work", "") 253 + err := handler.List(ctx, true, false, "", "", "work", "", "") 254 254 if err != nil { 255 255 t.Errorf("ListTasks with project filter failed: %v", err) 256 256 } 257 257 }) 258 258 259 259 t.Run("show all tasks (static mode)", func(t *testing.T) { 260 - err := handler.List(ctx, true, true, "", "", "", "") 260 + err := handler.List(ctx, true, true, "", "", "", "", "") 261 261 if err != nil { 262 262 t.Errorf("ListTasks with show all failed: %v", err) 263 263 } ··· 1479 1479 } 1480 1480 defer handler.Close() 1481 1481 1482 - err = handler.Create(ctx, "Test Task 1", "high", "test-project", "test-context", "", "", "", "", "", []string{"tag1"}) 1482 + err = handler.Create(ctx, "Test Task 1", "high", "test-project", "test-context", "", "", "", "", "", "", "", []string{"tag1"}) 1483 1483 if err != nil { 1484 1484 t.Fatalf("Failed to create test task: %v", err) 1485 1485 } 1486 1486 1487 - err = handler.Create(ctx, "Test Task 2", "medium", "test-project", "test-context", "", "", "", "", "", []string{"tag2"}) 1487 + err = handler.Create(ctx, "Test Task 2", "medium", "test-project", "test-context", "", "", "", "", "", "", "", []string{"tag2"}) 1488 1488 if err != nil { 1489 1489 t.Fatalf("Failed to create test task: %v", err) 1490 1490 }
+98 -5
internal/models/models.go
··· 112 112 Context string `json:"context,omitempty"` 113 113 Tags []string `json:"tags,omitempty"` 114 114 Due *time.Time `json:"due,omitempty"` 115 + Wait *time.Time `json:"wait,omitempty"` // Task is not actionable until this date 116 + Scheduled *time.Time `json:"scheduled,omitempty"` // Task is scheduled to start on this date 115 117 Entry time.Time `json:"entry"` 116 118 Modified time.Time `json:"modified"` 117 119 End *time.Time `json:"end,omitempty"` // Completion time ··· 342 344 // HasDueDate returns true if the task has a due date set. 343 345 func (t *Task) HasDueDate() bool { return t.Due != nil } 344 346 347 + // IsWaiting returns true if the task has a wait date and it hasn't passed yet. 348 + func (t *Task) IsWaiting(now time.Time) bool { 349 + return t.Wait != nil && now.Before(*t.Wait) 350 + } 351 + 352 + // HasWaitDate returns true if the task has a wait date set. 353 + func (t *Task) HasWaitDate() bool { return t.Wait != nil } 354 + 355 + // IsScheduled returns true if the task has a scheduled date. 356 + func (t *Task) IsScheduled() bool { return t.Scheduled != nil } 357 + 358 + // IsActionable returns true if the task can be worked on now. 359 + // A task is actionable if it's not waiting, not blocked, and not completed. 360 + func (t *Task) IsActionable(now time.Time) bool { 361 + if t.IsCompleted() || t.IsDone() || t.IsAbandoned() || t.IsBlocked() { 362 + return false 363 + } 364 + if t.IsWaiting(now) { 365 + return false 366 + } 367 + return true 368 + } 369 + 345 370 // IsRecurring returns true if the task has recurrence defined. 346 371 func (t *Task) IsRecurring() bool { return t.Recur != "" } 347 372 ··· 358 383 return slices.Contains(other.DependsOn, t.UUID) 359 384 } 360 385 361 - // Urgency computes a score based on priority, due date, and tags. 362 - // This can be expanded later with weights. 386 + // Urgency computes a comprehensive score based on multiple factors. 387 + // Higher score means more urgent. Score components: 388 + // - Priority: 0-10 based on priority weight 389 + // - Due date: 0-12 based on proximity (overdue gets highest) 390 + // - Scheduled: 0-4 if scheduled soon 391 + // - Age: 0-2 for old tasks 392 + // - Tags: 0.5 per tag (capped at 2.0) 393 + // - Waiting: -5.0 if not yet actionable 394 + // - Blocked: -3.0 if has incomplete dependencies 363 395 func (t *Task) Urgency(now time.Time) float64 { 396 + if !t.IsActionable(now) { 397 + if t.IsWaiting(now) { 398 + return -5.0 399 + } 400 + if t.IsBlocked() { 401 + return -3.0 402 + } 403 + return -10.0 404 + } 405 + 364 406 score := 0.0 365 - if t.Priority != "" { 366 - score += 1.0 407 + 408 + if t.HasPriority() { 409 + weight := t.GetPriorityWeight() 410 + if weight >= 20 { 411 + score += float64(weight-15) / 2.0 412 + } else if weight > 0 { 413 + score += float64(weight) * 2.0 414 + } 415 + } 416 + 417 + if t.HasDueDate() { 418 + daysUntilDue := t.Due.Sub(now).Hours() / 24.0 419 + if daysUntilDue < 0 { 420 + overdueDays := -daysUntilDue 421 + score += 12.0 + min(overdueDays*0.5, 3.0) 422 + } else if daysUntilDue <= 1 { 423 + score += 10.0 424 + } else if daysUntilDue <= 3 { 425 + score += 8.0 426 + } else if daysUntilDue <= 7 { 427 + score += 6.0 428 + } else if daysUntilDue <= 14 { 429 + score += 4.0 430 + } else if daysUntilDue <= 30 { 431 + score += 2.0 432 + } 367 433 } 368 - if t.IsOverdue(now) { 434 + 435 + if t.IsScheduled() { 436 + daysUntilScheduled := t.Scheduled.Sub(now).Hours() / 24.0 437 + if daysUntilScheduled <= 0 { 438 + score += 4.0 439 + } else if daysUntilScheduled <= 1 { 440 + score += 3.0 441 + } else if daysUntilScheduled <= 3 { 442 + score += 2.0 443 + } else if daysUntilScheduled <= 7 { 444 + score += 1.0 445 + } 446 + } 447 + 448 + age := now.Sub(t.Entry).Hours() / 24.0 449 + if age > 90 { 369 450 score += 2.0 451 + } else if age > 30 { 452 + score += 1.5 453 + } else if age > 14 { 454 + score += 1.0 455 + } else if age > 7 { 456 + score += 0.5 370 457 } 458 + 371 459 if len(t.Tags) > 0 { 460 + score += min(float64(len(t.Tags))*0.5, 2.0) 461 + } 462 + 463 + if t.Project != "" { 372 464 score += 0.5 373 465 } 466 + 374 467 return score 375 468 } 376 469
+4 -4
internal/repo/queries.go
··· 20 20 ) 21 21 22 22 const ( 23 - taskColumns = "id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations, recur, until, parent_uuid" 23 + taskColumns = "id, uuid, description, status, priority, project, context, tags, due, wait, scheduled, entry, modified, end, start, annotations, recur, until, parent_uuid" 24 24 queryTaskByID = "SELECT " + taskColumns + " FROM tasks WHERE id = ?" 25 25 queryTaskByUUID = "SELECT " + taskColumns + " FROM tasks WHERE uuid = ?" 26 26 queryTaskInsert = ` 27 27 INSERT INTO tasks ( 28 28 uuid, description, status, priority, project, context, 29 - tags, due, entry, modified, end, start, annotations, 29 + tags, due, wait, scheduled, entry, modified, end, start, annotations, 30 30 recur, until, parent_uuid 31 31 ) 32 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 32 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 33 33 queryTaskUpdate = ` 34 34 UPDATE tasks SET 35 35 uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, 36 - tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?, 36 + tags = ?, due = ?, wait = ?, scheduled = ?, modified = ?, end = ?, start = ?, annotations = ?, 37 37 recur = ?, until = ?, parent_uuid = ? 38 38 WHERE id = ?` 39 39 queryTaskDelete = "DELETE FROM tasks WHERE id = ?"
+6 -6
internal/repo/task_repository.go
··· 71 71 if err := s.Scan( 72 72 &task.ID, &task.UUID, &task.Description, &task.Status, &priority, 73 73 &project, &context, &tags, 74 - &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 74 + &task.Due, &task.Wait, &task.Scheduled, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 75 75 &task.Recur, &task.Until, &parentUUID, 76 76 ); err != nil { 77 77 return nil, err ··· 160 160 161 161 result, err := r.db.ExecContext(ctx, queryTaskInsert, 162 162 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 163 - tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations, 163 + tags, task.Due, task.Wait, task.Scheduled, task.Entry, task.Modified, task.End, task.Start, annotations, 164 164 task.Recur, task.Until, task.ParentUUID, 165 165 ) 166 166 if err != nil { ··· 213 213 214 214 if _, err = r.db.ExecContext(ctx, queryTaskUpdate, 215 215 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 216 - tags, task.Due, task.Modified, task.End, task.Start, annotations, 216 + tags, task.Due, task.Wait, task.Scheduled, task.Modified, task.End, task.Start, annotations, 217 217 task.Recur, task.Until, task.ParentUUID, 218 218 task.ID, 219 219 ); err != nil { ··· 518 518 func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) { 519 519 query := ` 520 520 SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 521 - t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, 521 + t.tags, t.due, t.wait, t.scheduled, t.entry, t.modified, t.end, t.start, t.annotations, 522 522 t.recur, t.until, t.parent_uuid 523 523 FROM tasks t, json_each(t.tags) 524 524 WHERE t.tags != '' AND t.tags IS NOT NULL AND json_each.value = ? ··· 684 684 func (r *TaskRepository) GetDependents(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 685 685 query := ` 686 686 SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 687 - t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid 687 + 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 688 688 FROM tasks t JOIN task_dependencies d ON t.uuid = d.task_uuid WHERE d.depends_on_uuid = ?` 689 689 690 690 tasks, err := r.queryMany(ctx, query, blockingUUID) ··· 704 704 func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 705 705 query := ` 706 706 SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 707 - t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid 707 + 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 708 708 FROM tasks t 709 709 JOIN task_dependencies d ON t.uuid = d.task_uuid 710 710 WHERE d.depends_on_uuid = ?`
+2
internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_down.sql
··· 1 + ALTER TABLE tasks DROP COLUMN wait; 2 + ALTER TABLE tasks DROP COLUMN scheduled;
+2
internal/store/sql/migrations/0009_add_wait_scheduled_to_tasks_up.sql
··· 1 + ALTER TABLE tasks ADD COLUMN wait DATETIME; 2 + ALTER TABLE tasks ADD COLUMN scheduled DATETIME;