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 &cobra.Group{ID: "task-ops", Title: "Basic Operations"}, 34 &cobra.Group{ID: "task-meta", Title: "Metadata"}, 35 &cobra.Group{ID: "task-tracking", Title: "Tracking"}, 36 ) 37 38 for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ ··· 59 root.AddCommand(cmd) 60 } 61 62 return root 63 } 64 ··· 84 project, _ := c.Flags().GetString("project") 85 context, _ := c.Flags().GetString("context") 86 due, _ := c.Flags().GetString("due") 87 recur, _ := c.Flags().GetString("recur") 88 until, _ := c.Flags().GetString("until") 89 parent, _ := c.Flags().GetString("parent") ··· 91 tags, _ := c.Flags().GetStringSlice("tags") 92 93 defer h.Close() 94 - return h.Create(c.Context(), description, priority, project, context, due, recur, until, parent, dependsOn, tags) 95 }, 96 } 97 addCommonTaskFlags(cmd) 98 addDueDateFlag(cmd) 99 addRecurrenceFlags(cmd) 100 addParentFlag(cmd) 101 addDependencyFlags(cmd) ··· 120 priority, _ := c.Flags().GetString("priority") 121 project, _ := c.Flags().GetString("project") 122 context, _ := c.Flags().GetString("context") 123 124 defer h.Close() 125 - return h.List(c.Context(), static, showAll, status, priority, project, context) 126 }, 127 } 128 cmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)") ··· 132 cmd.Flags().String("priority", "", "Filter by priority") 133 cmd.Flags().String("project", "", "Filter by project") 134 cmd.Flags().String("context", "", "Filter by context") 135 136 return cmd 137 } ··· 449 450 root.AddCommand(setCmd, clearCmd, showCmd) 451 return root 452 } 453 454 func taskDependCmd(h *handlers.TaskHandler) *cobra.Command {
··· 33 &cobra.Group{ID: "task-ops", Title: "Basic Operations"}, 34 &cobra.Group{ID: "task-meta", Title: "Metadata"}, 35 &cobra.Group{ID: "task-tracking", Title: "Tracking"}, 36 + &cobra.Group{ID: "task-reports", Title: "Reports & Views"}, 37 ) 38 39 for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ ··· 60 root.AddCommand(cmd) 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 + 71 return root 72 } 73 ··· 93 project, _ := c.Flags().GetString("project") 94 context, _ := c.Flags().GetString("context") 95 due, _ := c.Flags().GetString("due") 96 + wait, _ := c.Flags().GetString("wait") 97 + scheduled, _ := c.Flags().GetString("scheduled") 98 recur, _ := c.Flags().GetString("recur") 99 until, _ := c.Flags().GetString("until") 100 parent, _ := c.Flags().GetString("parent") ··· 102 tags, _ := c.Flags().GetStringSlice("tags") 103 104 defer h.Close() 105 + // TODO: Make a CreateTask struct 106 + return h.Create(c.Context(), description, priority, project, context, due, wait, scheduled, recur, until, parent, dependsOn, tags) 107 }, 108 } 109 addCommonTaskFlags(cmd) 110 addDueDateFlag(cmd) 111 + addWaitScheduledFlags(cmd) 112 addRecurrenceFlags(cmd) 113 addParentFlag(cmd) 114 addDependencyFlags(cmd) ··· 133 priority, _ := c.Flags().GetString("priority") 134 project, _ := c.Flags().GetString("project") 135 context, _ := c.Flags().GetString("context") 136 + sortBy, _ := c.Flags().GetString("sort") 137 138 defer h.Close() 139 + // TODO: TaskFilter struct 140 + return h.List(c.Context(), static, showAll, status, priority, project, context, sortBy) 141 }, 142 } 143 cmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)") ··· 147 cmd.Flags().String("priority", "", "Filter by priority") 148 cmd.Flags().String("project", "", "Filter by project") 149 cmd.Flags().String("context", "", "Filter by context") 150 + cmd.Flags().String("sort", "", "Sort by (urgency)") 151 152 return cmd 153 } ··· 465 466 root.AddCommand(setCmd, clearCmd, showCmd) 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 547 } 548 549 func taskDependCmd(h *handlers.TaskHandler) *cobra.Command {
+5
cmd/task_flags.go
··· 31 func addDueDateFlag(cmd *cobra.Command) { 32 cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") 33 }
··· 31 func addDueDateFlag(cmd *cobra.Command) { 32 cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") 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 127 ### Tasks 128 129 - - [ ] Model 130 - - [ ] Dependencies 131 - - [ ] Recurrence (`recur`, `until`, templates) 132 - - [ ] Wait/scheduled dates 133 - - [ ] Urgency scoring 134 - [ ] Operations 135 - [ ] `annotate` 136 - [ ] Bulk edit and undo/history 137 - [ ] `$EDITOR` integration 138 - - [ ] Reports and Views 139 - - [ ] Next actions 140 - - [ ] Completed/waiting/blocked reports 141 - - [ ] Calendar view 142 - - [ ] Sorting and urgency-based views 143 - [ ] Queries and Filters 144 - [ ] Rich query language 145 - [ ] Saved filters and aliases ··· 442 | Tasks | Time tracking | Complete | 443 | Tasks | Dependencies | Complete | 444 | Tasks | Recurrence | Complete | 445 - | Tasks | Wait/scheduled | Planned | 446 - | Tasks | Urgency scoring | Planned | 447 | Notes | CRUD | Complete | 448 | Notes | Search/tagging | Planned | 449 | Publications | AT Protocol sync | Complete |
··· 126 127 ### Tasks 128 129 + - [x] Model 130 + - [x] Dependencies 131 + - [x] Recurrence (`recur`, `until`, templates) 132 + - [x] Wait/scheduled dates 133 + - [x] Urgency scoring 134 - [ ] Operations 135 - [ ] `annotate` 136 - [ ] Bulk edit and undo/history 137 - [ ] `$EDITOR` integration 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 - [ ] Queries and Filters 144 - [ ] Rich query language 145 - [ ] Saved filters and aliases ··· 442 | Tasks | Time tracking | Complete | 443 | Tasks | Dependencies | Complete | 444 | Tasks | Recurrence | Complete | 445 + | Tasks | Wait/scheduled | Complete | 446 + | Tasks | Urgency scoring | Complete | 447 + | Tasks | Reports and views | Complete | 448 | Notes | CRUD | Complete | 449 | Notes | Search/tagging | Planned | 450 | Publications | AT Protocol sync | Complete |
+7 -1
internal/handlers/task_helpers.go
··· 18 Context string 19 Tags []string 20 Due string 21 Recur string 22 Until string 23 ParentUUID string ··· 25 } 26 27 // 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 29 func parseDescription(text string) *ParsedTaskData { 30 parsed := &ParsedTaskData{Tags: []string{}, DependsOn: []string{}} 31 words := strings.Fields(text) ··· 41 parsed.Tags = append(parsed.Tags, strings.TrimPrefix(word, "#")) 42 case strings.HasPrefix(word, "due:"): 43 parsed.Due = strings.TrimPrefix(word, "due:") 44 case strings.HasPrefix(word, "recur:"): 45 parsed.Recur = strings.TrimPrefix(word, "recur:") 46 case strings.HasPrefix(word, "until:"):
··· 18 Context string 19 Tags []string 20 Due string 21 + Wait string 22 + Scheduled string 23 Recur string 24 Until string 25 ParentUUID string ··· 27 } 28 29 // parseDescription extracts inline metadata from description text 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 31 func parseDescription(text string) *ParsedTaskData { 32 parsed := &ParsedTaskData{Tags: []string{}, DependsOn: []string{}} 33 words := strings.Fields(text) ··· 43 parsed.Tags = append(parsed.Tags, strings.TrimPrefix(word, "#")) 44 case strings.HasPrefix(word, "due:"): 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:") 50 case strings.HasPrefix(word, "recur:"): 51 parsed.Recur = strings.TrimPrefix(word, "recur:") 52 case strings.HasPrefix(word, "until:"):
+278 -5
internal/handlers/tasks.go
··· 6 "fmt" 7 "os" 8 "slices" 9 "strconv" 10 "strings" 11 "time" ··· 51 } 52 53 // 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 if description == "" { 56 return fmt.Errorf("task description required") 57 } ··· 67 if due != "" { 68 parsed.Due = due 69 } 70 if recur != "" { 71 parsed.Recur = recur 72 } ··· 100 task.Due = &dueTime 101 } else { 102 return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) 103 } 104 } 105 ··· 154 } 155 156 // List lists all tasks with optional filtering 157 - func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project, context string) error { 158 if static { 159 - return h.listTasksStatic(ctx, showAll, status, priority, project, context) 160 } 161 162 return h.listTasksInteractive(ctx, showAll, status, priority, project, context) 163 } 164 165 - func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project, context string) error { 166 opts := repo.TaskListOptions{ 167 Status: status, 168 Priority: priority, ··· 179 return fmt.Errorf("failed to list tasks: %w", err) 180 } 181 182 if len(tasks) == 0 { 183 fmt.Printf("No tasks found matching criteria\n") 184 return nil 185 } 186 187 - fmt.Printf("Found %d task(s):\n\n", len(tasks)) 188 for _, task := range tasks { 189 printTask(task) 190 } 191 ··· 958 959 return nil 960 }
··· 6 "fmt" 7 "os" 8 "slices" 9 + "sort" 10 "strconv" 11 "strings" 12 "time" ··· 52 } 53 54 // Create creates a new task 55 + func (h *TaskHandler) Create(ctx context.Context, description, priority, project, context, due, wait, scheduled, recur, until, parentUUID, dependsOn string, tags []string) error { 56 if description == "" { 57 return fmt.Errorf("task description required") 58 } ··· 68 if due != "" { 69 parsed.Due = due 70 } 71 + if wait != "" { 72 + parsed.Wait = wait 73 + } 74 + if scheduled != "" { 75 + parsed.Scheduled = scheduled 76 + } 77 if recur != "" { 78 parsed.Recur = recur 79 } ··· 107 task.Due = &dueTime 108 } else { 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) 126 } 127 } 128 ··· 177 } 178 179 // List lists all tasks with optional filtering 180 + func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project, context, sortBy string) error { 181 if static { 182 + return h.listTasksStatic(ctx, showAll, status, priority, project, context, sortBy) 183 } 184 185 return h.listTasksInteractive(ctx, showAll, status, priority, project, context) 186 } 187 188 + func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project, context, sortBy string) error { 189 opts := repo.TaskListOptions{ 190 Status: status, 191 Priority: priority, ··· 202 return fmt.Errorf("failed to list tasks: %w", err) 203 } 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 + 212 if len(tasks) == 0 { 213 fmt.Printf("No tasks found matching criteria\n") 214 return nil 215 } 216 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 + 223 for _, task := range tasks { 224 + if sortBy == "urgency" { 225 + urgency := task.Urgency(time.Now()) 226 + fmt.Printf("[%.1f] ", urgency) 227 + } 228 printTask(task) 229 } 230 ··· 997 998 return nil 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 78 t.Run("creates task successfully", func(t *testing.T) { 79 desc := "Buy groceries and cook dinner" 80 - err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) 81 shared.AssertNoError(t, err, "CreateTask should succeed") 82 83 tasks, err := handler.repos.Tasks.GetPending(ctx) ··· 104 105 t.Run("fails with empty description", func(t *testing.T) { 106 desc := "" 107 - err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) 108 shared.AssertError(t, err, "Expected error for empty description") 109 shared.AssertContains(t, err.Error(), "task description required", "Error message should mention required description") 110 }) ··· 116 due := "2024-12-31" 117 tags := []string{"urgent", "work"} 118 119 - err := handler.Create(ctx, description, priority, project, "test-context", due, "", "", "", "", tags) 120 if err != nil { 121 t.Errorf("CreateTask with flags failed: %v", err) 122 } ··· 171 desc := "Task with invalid date" 172 invalidDue := "invalid-date" 173 174 - err := handler.Create(ctx, desc, "", "", "", invalidDue, "", "", "", "", []string{}) 175 if err == nil { 176 t.Error("Expected error for invalid due date format") 177 } ··· 185 ctx, cancel := context.WithCancel(ctx) 186 cancel() 187 188 - err := handler.Create(ctx, "Test task", "", "", "", "", "", "", "", "", []string{}) 189 if err == nil { 190 t.Error("Expected error when repository Create fails") 191 } ··· 229 } 230 231 t.Run("lists pending tasks by default (static mode)", func(t *testing.T) { 232 - err := handler.List(ctx, true, false, "", "", "", "") 233 if err != nil { 234 t.Errorf("ListTasks failed: %v", err) 235 } 236 }) 237 238 t.Run("filters by status (static mode)", func(t *testing.T) { 239 - err := handler.List(ctx, true, false, "completed", "", "", "") 240 if err != nil { 241 t.Errorf("ListTasks with status filter failed: %v", err) 242 } 243 }) 244 245 t.Run("filters by priority (static mode)", func(t *testing.T) { 246 - err := handler.List(ctx, true, false, "", "A", "", "") 247 if err != nil { 248 t.Errorf("ListTasks with priority filter failed: %v", err) 249 } 250 }) 251 252 t.Run("filters by project (static mode)", func(t *testing.T) { 253 - err := handler.List(ctx, true, false, "", "", "work", "") 254 if err != nil { 255 t.Errorf("ListTasks with project filter failed: %v", err) 256 } 257 }) 258 259 t.Run("show all tasks (static mode)", func(t *testing.T) { 260 - err := handler.List(ctx, true, true, "", "", "", "") 261 if err != nil { 262 t.Errorf("ListTasks with show all failed: %v", err) 263 } ··· 1479 } 1480 defer handler.Close() 1481 1482 - err = handler.Create(ctx, "Test Task 1", "high", "test-project", "test-context", "", "", "", "", "", []string{"tag1"}) 1483 if err != nil { 1484 t.Fatalf("Failed to create test task: %v", err) 1485 } 1486 1487 - err = handler.Create(ctx, "Test Task 2", "medium", "test-project", "test-context", "", "", "", "", "", []string{"tag2"}) 1488 if err != nil { 1489 t.Fatalf("Failed to create test task: %v", err) 1490 }
··· 77 78 t.Run("creates task successfully", func(t *testing.T) { 79 desc := "Buy groceries and cook dinner" 80 + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", "", "", []string{}) 81 shared.AssertNoError(t, err, "CreateTask should succeed") 82 83 tasks, err := handler.repos.Tasks.GetPending(ctx) ··· 104 105 t.Run("fails with empty description", func(t *testing.T) { 106 desc := "" 107 + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", "", "", []string{}) 108 shared.AssertError(t, err, "Expected error for empty description") 109 shared.AssertContains(t, err.Error(), "task description required", "Error message should mention required description") 110 }) ··· 116 due := "2024-12-31" 117 tags := []string{"urgent", "work"} 118 119 + err := handler.Create(ctx, description, priority, project, "test-context", due, "", "", "", "", "", "", tags) 120 if err != nil { 121 t.Errorf("CreateTask with flags failed: %v", err) 122 } ··· 171 desc := "Task with invalid date" 172 invalidDue := "invalid-date" 173 174 + err := handler.Create(ctx, desc, "", "", "", invalidDue, "", "", "", "", "", "", []string{}) 175 if err == nil { 176 t.Error("Expected error for invalid due date format") 177 } ··· 185 ctx, cancel := context.WithCancel(ctx) 186 cancel() 187 188 + err := handler.Create(ctx, "Test task", "", "", "", "", "", "", "", "", "", "", []string{}) 189 if err == nil { 190 t.Error("Expected error when repository Create fails") 191 } ··· 229 } 230 231 t.Run("lists pending tasks by default (static mode)", func(t *testing.T) { 232 + err := handler.List(ctx, true, false, "", "", "", "", "") 233 if err != nil { 234 t.Errorf("ListTasks failed: %v", err) 235 } 236 }) 237 238 t.Run("filters by status (static mode)", func(t *testing.T) { 239 + err := handler.List(ctx, true, false, "completed", "", "", "", "") 240 if err != nil { 241 t.Errorf("ListTasks with status filter failed: %v", err) 242 } 243 }) 244 245 t.Run("filters by priority (static mode)", func(t *testing.T) { 246 + err := handler.List(ctx, true, false, "", "A", "", "", "") 247 if err != nil { 248 t.Errorf("ListTasks with priority filter failed: %v", err) 249 } 250 }) 251 252 t.Run("filters by project (static mode)", func(t *testing.T) { 253 + err := handler.List(ctx, true, false, "", "", "work", "", "") 254 if err != nil { 255 t.Errorf("ListTasks with project filter failed: %v", err) 256 } 257 }) 258 259 t.Run("show all tasks (static mode)", func(t *testing.T) { 260 + err := handler.List(ctx, true, true, "", "", "", "", "") 261 if err != nil { 262 t.Errorf("ListTasks with show all failed: %v", err) 263 } ··· 1479 } 1480 defer handler.Close() 1481 1482 + err = handler.Create(ctx, "Test Task 1", "high", "test-project", "test-context", "", "", "", "", "", "", "", []string{"tag1"}) 1483 if err != nil { 1484 t.Fatalf("Failed to create test task: %v", err) 1485 } 1486 1487 + err = handler.Create(ctx, "Test Task 2", "medium", "test-project", "test-context", "", "", "", "", "", "", "", []string{"tag2"}) 1488 if err != nil { 1489 t.Fatalf("Failed to create test task: %v", err) 1490 }
+98 -5
internal/models/models.go
··· 112 Context string `json:"context,omitempty"` 113 Tags []string `json:"tags,omitempty"` 114 Due *time.Time `json:"due,omitempty"` 115 Entry time.Time `json:"entry"` 116 Modified time.Time `json:"modified"` 117 End *time.Time `json:"end,omitempty"` // Completion time ··· 342 // HasDueDate returns true if the task has a due date set. 343 func (t *Task) HasDueDate() bool { return t.Due != nil } 344 345 // IsRecurring returns true if the task has recurrence defined. 346 func (t *Task) IsRecurring() bool { return t.Recur != "" } 347 ··· 358 return slices.Contains(other.DependsOn, t.UUID) 359 } 360 361 - // Urgency computes a score based on priority, due date, and tags. 362 - // This can be expanded later with weights. 363 func (t *Task) Urgency(now time.Time) float64 { 364 score := 0.0 365 - if t.Priority != "" { 366 - score += 1.0 367 } 368 - if t.IsOverdue(now) { 369 score += 2.0 370 } 371 if len(t.Tags) > 0 { 372 score += 0.5 373 } 374 return score 375 } 376
··· 112 Context string `json:"context,omitempty"` 113 Tags []string `json:"tags,omitempty"` 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 117 Entry time.Time `json:"entry"` 118 Modified time.Time `json:"modified"` 119 End *time.Time `json:"end,omitempty"` // Completion time ··· 344 // HasDueDate returns true if the task has a due date set. 345 func (t *Task) HasDueDate() bool { return t.Due != nil } 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 + 370 // IsRecurring returns true if the task has recurrence defined. 371 func (t *Task) IsRecurring() bool { return t.Recur != "" } 372 ··· 383 return slices.Contains(other.DependsOn, t.UUID) 384 } 385 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 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 + 406 score := 0.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 + } 433 } 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 { 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 457 } 458 + 459 if len(t.Tags) > 0 { 460 + score += min(float64(len(t.Tags))*0.5, 2.0) 461 + } 462 + 463 + if t.Project != "" { 464 score += 0.5 465 } 466 + 467 return score 468 } 469
+4 -4
internal/repo/queries.go
··· 20 ) 21 22 const ( 23 - taskColumns = "id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations, recur, until, parent_uuid" 24 queryTaskByID = "SELECT " + taskColumns + " FROM tasks WHERE id = ?" 25 queryTaskByUUID = "SELECT " + taskColumns + " FROM tasks WHERE uuid = ?" 26 queryTaskInsert = ` 27 INSERT INTO tasks ( 28 uuid, description, status, priority, project, context, 29 - tags, due, entry, modified, end, start, annotations, 30 recur, until, parent_uuid 31 ) 32 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 33 queryTaskUpdate = ` 34 UPDATE tasks SET 35 uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, 36 - tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?, 37 recur = ?, until = ?, parent_uuid = ? 38 WHERE id = ?` 39 queryTaskDelete = "DELETE FROM tasks WHERE id = ?"
··· 20 ) 21 22 const ( 23 + taskColumns = "id, uuid, description, status, priority, project, context, tags, due, wait, scheduled, entry, modified, end, start, annotations, recur, until, parent_uuid" 24 queryTaskByID = "SELECT " + taskColumns + " FROM tasks WHERE id = ?" 25 queryTaskByUUID = "SELECT " + taskColumns + " FROM tasks WHERE uuid = ?" 26 queryTaskInsert = ` 27 INSERT INTO tasks ( 28 uuid, description, status, priority, project, context, 29 + tags, due, wait, scheduled, entry, modified, end, start, annotations, 30 recur, until, parent_uuid 31 ) 32 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 33 queryTaskUpdate = ` 34 UPDATE tasks SET 35 uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, 36 + tags = ?, due = ?, wait = ?, scheduled = ?, modified = ?, end = ?, start = ?, annotations = ?, 37 recur = ?, until = ?, parent_uuid = ? 38 WHERE id = ?` 39 queryTaskDelete = "DELETE FROM tasks WHERE id = ?"
+6 -6
internal/repo/task_repository.go
··· 71 if err := s.Scan( 72 &task.ID, &task.UUID, &task.Description, &task.Status, &priority, 73 &project, &context, &tags, 74 - &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 75 &task.Recur, &task.Until, &parentUUID, 76 ); err != nil { 77 return nil, err ··· 160 161 result, err := r.db.ExecContext(ctx, queryTaskInsert, 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, 164 task.Recur, task.Until, task.ParentUUID, 165 ) 166 if err != nil { ··· 213 214 if _, err = r.db.ExecContext(ctx, queryTaskUpdate, 215 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 216 - tags, task.Due, task.Modified, task.End, task.Start, annotations, 217 task.Recur, task.Until, task.ParentUUID, 218 task.ID, 219 ); err != nil { ··· 518 func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) { 519 query := ` 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, 522 t.recur, t.until, t.parent_uuid 523 FROM tasks t, json_each(t.tags) 524 WHERE t.tags != '' AND t.tags IS NOT NULL AND json_each.value = ? ··· 684 func (r *TaskRepository) GetDependents(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 685 query := ` 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 688 FROM tasks t JOIN task_dependencies d ON t.uuid = d.task_uuid WHERE d.depends_on_uuid = ?` 689 690 tasks, err := r.queryMany(ctx, query, blockingUUID) ··· 704 func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 705 query := ` 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 708 FROM tasks t 709 JOIN task_dependencies d ON t.uuid = d.task_uuid 710 WHERE d.depends_on_uuid = ?`
··· 71 if err := s.Scan( 72 &task.ID, &task.UUID, &task.Description, &task.Status, &priority, 73 &project, &context, &tags, 74 + &task.Due, &task.Wait, &task.Scheduled, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 75 &task.Recur, &task.Until, &parentUUID, 76 ); err != nil { 77 return nil, err ··· 160 161 result, err := r.db.ExecContext(ctx, queryTaskInsert, 162 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 163 + tags, task.Due, task.Wait, task.Scheduled, task.Entry, task.Modified, task.End, task.Start, annotations, 164 task.Recur, task.Until, task.ParentUUID, 165 ) 166 if err != nil { ··· 213 214 if _, err = r.db.ExecContext(ctx, queryTaskUpdate, 215 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 216 + tags, task.Due, task.Wait, task.Scheduled, task.Modified, task.End, task.Start, annotations, 217 task.Recur, task.Until, task.ParentUUID, 218 task.ID, 219 ); err != nil { ··· 518 func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) { 519 query := ` 520 SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 521 + t.tags, t.due, t.wait, t.scheduled, t.entry, t.modified, t.end, t.start, t.annotations, 522 t.recur, t.until, t.parent_uuid 523 FROM tasks t, json_each(t.tags) 524 WHERE t.tags != '' AND t.tags IS NOT NULL AND json_each.value = ? ··· 684 func (r *TaskRepository) GetDependents(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 685 query := ` 686 SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 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 FROM tasks t JOIN task_dependencies d ON t.uuid = d.task_uuid WHERE d.depends_on_uuid = ?` 689 690 tasks, err := r.queryMany(ctx, query, blockingUUID) ··· 704 func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 705 query := ` 706 SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 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 FROM tasks t 709 JOIN task_dependencies d ON t.uuid = d.task_uuid 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;