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

feat: time tracking & timesheet

+1542 -256
+27 -242
cmd/commands.go
··· 28 28 Use: "noteleaf", 29 29 Long: ui.Georgia.ColoredInViewport(), 30 30 Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists", 31 - RunE: func(cmd *cobra.Command, args []string) error { 31 + RunE: func(c *cobra.Command, args []string) error { 32 32 if len(args) == 0 { 33 - return cmd.Help() 33 + return c.Help() 34 34 } 35 35 36 36 output := strings.Join(args, " ") ··· 44 44 45 45 root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) 46 46 root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) 47 - 48 47 return root 49 48 } 50 49 51 50 func todoCmd() *cobra.Command { 52 - root := &cobra.Command{ 53 - Use: "todo", 54 - Aliases: []string{"task"}, 55 - Short: "task management", 56 - } 57 - 58 - addCmd := &cobra.Command{ 59 - Use: "add [description]", 60 - Short: "Add a new task", 61 - Aliases: []string{"create", "new"}, 62 - Args: cobra.MinimumNArgs(1), 63 - RunE: func(cmd *cobra.Command, args []string) error { 64 - priority, _ := cmd.Flags().GetString("priority") 65 - project, _ := cmd.Flags().GetString("project") 66 - due, _ := cmd.Flags().GetString("due") 67 - tags, _ := cmd.Flags().GetStringSlice("tags") 51 + root := &cobra.Command{Use: "todo", Aliases: []string{"task"}, Short: "task management"} 68 52 69 - handler, err := handlers.NewTaskHandler() 70 - if err != nil { 71 - return err 72 - } 73 - defer handler.Close() 74 - return handler.Create(cmd.Context(), args, priority, project, due, tags) 75 - }, 53 + handler, err := handlers.NewTaskHandler() 54 + if err != nil { 55 + log.Fatalf("failed to create task handler: %v", err) 76 56 } 77 - addCmd.Flags().StringP("priority", "p", "", "Set task priority") 78 - addCmd.Flags().String("project", "", "Set task project") 79 - addCmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") 80 - addCmd.Flags().StringSliceP("tags", "t", []string{}, "Add tags to task") 81 - root.AddCommand(addCmd) 82 57 83 - listCmd := &cobra.Command{ 84 - Use: "list", 85 - Short: "List tasks", 86 - Aliases: []string{"ls"}, 87 - Long: `List tasks with optional filtering and display modes. 88 - 89 - By default, shows tasks in an interactive TaskWarrior-like interface. 90 - Use --static to show a simple text list instead. 91 - Use --all to show all tasks, otherwise only pending tasks are shown.`, 92 - RunE: func(cmd *cobra.Command, args []string) error { 93 - static, _ := cmd.Flags().GetBool("static") 94 - showAll, _ := cmd.Flags().GetBool("all") 95 - status, _ := cmd.Flags().GetString("status") 96 - priority, _ := cmd.Flags().GetString("priority") 97 - project, _ := cmd.Flags().GetString("project") 98 - 99 - handler, err := handlers.NewTaskHandler() 100 - if err != nil { 101 - return err 102 - } 103 - defer handler.Close() 104 - return handler.List(cmd.Context(), static, showAll, status, priority, project) 105 - }, 58 + for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 59 + addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, 60 + deleteTaskCmd, taskProjectsCmd, taskTagsCmd, taskContextsCmd, 61 + taskCompleteCmd, taskStartCmd, taskStopCmd, timesheetViewCmd, 62 + } { 63 + cmd := init(handler) 64 + root.AddCommand(cmd) 106 65 } 107 - listCmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)") 108 - listCmd.Flags().Bool("static", false, "Use static text output instead of interactive") 109 - listCmd.Flags().BoolP("all", "a", false, "Show all tasks (default: pending only)") 110 - listCmd.Flags().String("status", "", "Filter by status") 111 - listCmd.Flags().String("priority", "", "Filter by priority") 112 - listCmd.Flags().String("project", "", "Filter by project") 113 - root.AddCommand(listCmd) 114 - 115 - viewCmd := &cobra.Command{ 116 - Use: "view [task-id]", 117 - Short: "View task by ID", 118 - Args: cobra.ExactArgs(1), 119 - RunE: func(cmd *cobra.Command, args []string) error { 120 - format, _ := cmd.Flags().GetString("format") 121 - jsonOutput, _ := cmd.Flags().GetBool("json") 122 - noMetadata, _ := cmd.Flags().GetBool("no-metadata") 123 - 124 - handler, err := handlers.NewTaskHandler() 125 - if err != nil { 126 - return err 127 - } 128 - defer handler.Close() 129 - return handler.View(cmd.Context(), args, format, jsonOutput, noMetadata) 130 - }, 131 - } 132 - viewCmd.Flags().String("format", "detailed", "Output format (detailed, brief)") 133 - viewCmd.Flags().Bool("json", false, "Output as JSON") 134 - viewCmd.Flags().Bool("no-metadata", false, "Hide creation/modification timestamps") 135 - root.AddCommand(viewCmd) 136 - 137 - updateCmd := &cobra.Command{ 138 - Use: "update [task-id]", 139 - Short: "Update task properties", 140 - Args: cobra.ExactArgs(1), 141 - RunE: func(cmd *cobra.Command, args []string) error { 142 - taskID := args[0] 143 - description, _ := cmd.Flags().GetString("description") 144 - status, _ := cmd.Flags().GetString("status") 145 - priority, _ := cmd.Flags().GetString("priority") 146 - project, _ := cmd.Flags().GetString("project") 147 - due, _ := cmd.Flags().GetString("due") 148 - addTags, _ := cmd.Flags().GetStringSlice("add-tag") 149 - removeTags, _ := cmd.Flags().GetStringSlice("remove-tag") 150 - 151 - handler, err := handlers.NewTaskHandler() 152 - if err != nil { 153 - return err 154 - } 155 - defer handler.Close() 156 - return handler.Update(cmd.Context(), taskID, description, status, priority, project, due, addTags, removeTags) 157 - }, 158 - } 159 - updateCmd.Flags().String("description", "", "Update task description") 160 - updateCmd.Flags().String("status", "", "Update task status") 161 - updateCmd.Flags().StringP("priority", "p", "", "Update task priority") 162 - updateCmd.Flags().String("project", "", "Update task project") 163 - updateCmd.Flags().StringP("due", "d", "", "Update due date (YYYY-MM-DD)") 164 - updateCmd.Flags().StringSlice("add-tag", []string{}, "Add tags to task") 165 - updateCmd.Flags().StringSlice("remove-tag", []string{}, "Remove tags from task") 166 - root.AddCommand(updateCmd) 167 - 168 - editCmd := &cobra.Command{ 169 - Use: "edit [task-id]", 170 - Short: "Edit task interactively with status picker and priority toggle", 171 - Aliases: []string{"e"}, 172 - Args: cobra.ExactArgs(1), 173 - RunE: func(cmd *cobra.Command, args []string) error { 174 - taskID := args[0] 175 - handler, err := handlers.NewTaskHandler() 176 - if err != nil { 177 - return err 178 - } 179 - defer handler.Close() 180 - return handler.EditInteractive(cmd.Context(), taskID) 181 - }, 182 - } 183 - root.AddCommand(editCmd) 184 - 185 - root.AddCommand(&cobra.Command{ 186 - Use: "delete [task-id]", 187 - Short: "Delete a task", 188 - Args: cobra.ExactArgs(1), 189 - RunE: func(cmd *cobra.Command, args []string) error { 190 - handler, err := handlers.NewTaskHandler() 191 - if err != nil { 192 - return err 193 - } 194 - defer handler.Close() 195 - return handler.Delete(cmd.Context(), args) 196 - }, 197 - }) 198 - 199 - projectsCmd := &cobra.Command{ 200 - Use: "projects", 201 - Short: "List projects", 202 - Aliases: []string{"proj"}, 203 - RunE: func(cmd *cobra.Command, args []string) error { 204 - static, _ := cmd.Flags().GetBool("static") 205 - handler, err := handlers.NewTaskHandler() 206 - if err != nil { 207 - return err 208 - } 209 - defer handler.Close() 210 - return handler.ListProjects(cmd.Context(), static) 211 - }, 212 - } 213 - projectsCmd.Flags().Bool("static", false, "Use static text output instead of interactive") 214 - root.AddCommand(projectsCmd) 215 - 216 - tagsCmd := &cobra.Command{ 217 - Use: "tags", 218 - Short: "List tags", 219 - Aliases: []string{"t"}, 220 - RunE: func(cmd *cobra.Command, args []string) error { 221 - static, _ := cmd.Flags().GetBool("static") 222 - handler, err := handlers.NewTaskHandler() 223 - if err != nil { 224 - return err 225 - } 226 - defer handler.Close() 227 - return handler.ListTags(cmd.Context(), static) 228 - }, 229 - } 230 - tagsCmd.Flags().Bool("static", false, "Use static text output instead of interactive") 231 - root.AddCommand(tagsCmd) 232 - 233 - root.AddCommand(&cobra.Command{ 234 - Use: "contexts", 235 - Short: "List contexts (locations)", 236 - Aliases: []string{"loc", "ctx", "locations"}, 237 - RunE: func(cmd *cobra.Command, args []string) error { 238 - fmt.Println("Listing task contexts...") 239 - return nil 240 - }, 241 - }) 242 - 243 - root.AddCommand(&cobra.Command{ 244 - Use: "done [task-id]", 245 - Short: "Mark task as completed", 246 - Aliases: []string{"complete"}, 247 - Args: cobra.ExactArgs(1), 248 - RunE: func(cmd *cobra.Command, args []string) error { 249 - handler, err := handlers.NewTaskHandler() 250 - if err != nil { 251 - return err 252 - } 253 - defer handler.Close() 254 - return handler.Done(cmd.Context(), args) 255 - }, 256 - }) 257 66 258 67 return root 259 68 } 260 69 261 70 func mediaCmd() *cobra.Command { 262 - root := &cobra.Command{ 263 - Use: "media", 264 - Short: "Manage media queues (books, movies, TV shows)", 71 + cmd := &cobra.Command{Use: "media", Short: "Manage media queues (books, movies, TV shows)"} 72 + for _, init := range []func() *cobra.Command{bookMediaCmd, movieMediaCmd, tvMediaCmd} { 73 + cmd.AddCommand(init()) 265 74 } 266 - 267 - root.AddCommand(bookMediaCmd()) 268 - root.AddCommand(movieMediaCmd()) 269 - root.AddCommand(tvMediaCmd()) 270 - 271 - return root 75 + return cmd 272 76 } 273 77 274 78 func movieMediaCmd() *cobra.Command { 275 - root := &cobra.Command{ 276 - Use: "movie", 277 - Short: "Manage movie watch queue", 278 - } 79 + root := &cobra.Command{Use: "movie", Short: "Manage movie watch queue"} 279 80 280 81 root.AddCommand(&cobra.Command{ 281 82 Use: "add [title]", ··· 323 124 } 324 125 325 126 func tvMediaCmd() *cobra.Command { 326 - root := &cobra.Command{ 327 - Use: "tv", 328 - Short: "Manage TV show watch queue", 329 - } 127 + root := &cobra.Command{Use: "tv", Short: "Manage TV show watch queue"} 330 128 331 129 root.AddCommand(&cobra.Command{ 332 130 Use: "add [title]", ··· 374 172 } 375 173 376 174 func bookMediaCmd() *cobra.Command { 377 - root := &cobra.Command{ 378 - Use: "book", 379 - Short: "Manage reading list", 380 - } 175 + root := &cobra.Command{Use: "book", Short: "Manage reading list"} 381 176 382 - // book add - Search and add book to reading list 383 177 addCmd := &cobra.Command{ 384 178 Use: "add [search query...]", 385 179 Short: "Search and add book to reading list", ··· 395 189 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection") 396 190 root.AddCommand(addCmd) 397 191 398 - // book list - Show reading queue with progress 399 192 root.AddCommand(&cobra.Command{ 400 193 Use: "list [--all|--reading|--finished|--queued]", 401 194 Short: "Show reading queue with progress", ··· 404 197 }, 405 198 }) 406 199 407 - // book reading - Mark book as currently reading (alias for update status) 408 200 root.AddCommand(&cobra.Command{ 409 201 Use: "reading <id>", 410 202 Short: "Mark book as currently reading", ··· 414 206 }, 415 207 }) 416 208 417 - // book finished - Mark book as completed 418 209 root.AddCommand(&cobra.Command{ 419 210 Use: "finished <id>", 420 211 Short: "Mark book as completed", ··· 425 216 }, 426 217 }) 427 218 428 - // book remove - Remove from reading list 429 219 root.AddCommand(&cobra.Command{ 430 220 Use: "remove <id>", 431 221 Short: "Remove from reading list", ··· 436 226 }, 437 227 }) 438 228 439 - // book progress - Update reading progress percentage 440 229 root.AddCommand(&cobra.Command{ 441 230 Use: "progress <id> <percentage>", 442 231 Short: "Update reading progress percentage (0-100)", ··· 446 235 }, 447 236 }) 448 237 449 - // book update - Update book status 450 238 root.AddCommand(&cobra.Command{ 451 239 Use: "update <id> <status>", 452 240 Short: "Update book status (queued|reading|finished|removed)", ··· 460 248 } 461 249 462 250 func noteCmd() *cobra.Command { 463 - root := &cobra.Command{ 464 - Use: "note", 465 - Short: "Manage notes", 466 - } 251 + root := &cobra.Command{Use: "note", Short: "Manage notes"} 467 252 468 253 handler, err := handlers.NewNoteHandler() 469 254 if err != nil { ··· 613 398 root := &cobra.Command{ 614 399 Use: "setup", 615 400 Short: "Initialize and manage application setup", 616 - RunE: func(cmd *cobra.Command, args []string) error { 617 - return handlers.Setup(cmd.Context(), args) 401 + RunE: func(c *cobra.Command, args []string) error { 402 + return handlers.Setup(c.Context(), args) 618 403 }, 619 404 } 620 405 ··· 622 407 Use: "seed", 623 408 Short: "Populate database with test data", 624 409 Long: "Add sample tasks, books, and notes to the database for testing and demonstration purposes", 625 - RunE: func(cmd *cobra.Command, args []string) error { 626 - force, _ := cmd.Flags().GetBool("force") 627 - return handler.Seed(cmd.Context(), force) 410 + RunE: func(c *cobra.Command, args []string) error { 411 + force, _ := c.Flags().GetBool("force") 412 + return handler.Seed(c.Context(), force) 628 413 }, 629 414 } 630 415 seedCmd.Flags().BoolP("force", "f", false, "Clear existing data and re-seed") ··· 638 423 Use: "config [key] [value]", 639 424 Short: "Manage configuration", 640 425 Args: cobra.ExactArgs(2), 641 - RunE: func(cmd *cobra.Command, args []string) error { 426 + RunE: func(c *cobra.Command, args []string) error { 642 427 key, value := args[0], args[1] 643 428 fmt.Printf("Setting config %s = %s\n", key, value) 644 429 return nil
+249
cmd/task_commands.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/spf13/cobra" 7 + "github.com/stormlightlabs/noteleaf/internal/handlers" 8 + ) 9 + 10 + func addTaskCmd(h *handlers.TaskHandler) *cobra.Command { 11 + cmd := &cobra.Command{ 12 + Use: "add [description]", 13 + Short: "Add a new task", 14 + Aliases: []string{"create", "new"}, 15 + Args: cobra.MinimumNArgs(1), 16 + RunE: func(c *cobra.Command, args []string) error { 17 + priority, _ := c.Flags().GetString("priority") 18 + project, _ := c.Flags().GetString("project") 19 + due, _ := c.Flags().GetString("due") 20 + tags, _ := c.Flags().GetStringSlice("tags") 21 + 22 + defer h.Close() 23 + return h.Create(c.Context(), args, priority, project, due, tags) 24 + }, 25 + } 26 + cmd.Flags().StringP("priority", "p", "", "Set task priority") 27 + cmd.Flags().String("project", "", "Set task project") 28 + cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") 29 + cmd.Flags().StringSliceP("tags", "t", []string{}, "Add tags to task") 30 + 31 + return cmd 32 + } 33 + 34 + func listTaskCmd(h *handlers.TaskHandler) *cobra.Command { 35 + cmd := &cobra.Command{ 36 + Use: "list", 37 + Short: "List tasks", 38 + Aliases: []string{"ls"}, 39 + Long: `List tasks with optional filtering and display modes. 40 + 41 + By default, shows tasks in an interactive TaskWarrior-like interface. 42 + Use --static to show a simple text list instead. 43 + Use --all to show all tasks, otherwise only pending tasks are shown.`, 44 + RunE: func(c *cobra.Command, args []string) error { 45 + static, _ := c.Flags().GetBool("static") 46 + showAll, _ := c.Flags().GetBool("all") 47 + status, _ := c.Flags().GetString("status") 48 + priority, _ := c.Flags().GetString("priority") 49 + project, _ := c.Flags().GetString("project") 50 + 51 + defer h.Close() 52 + return h.List(c.Context(), static, showAll, status, priority, project) 53 + }, 54 + } 55 + cmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)") 56 + cmd.Flags().Bool("static", false, "Use static text output instead of interactive") 57 + cmd.Flags().BoolP("all", "a", false, "Show all tasks (default: pending only)") 58 + cmd.Flags().String("status", "", "Filter by status") 59 + cmd.Flags().String("priority", "", "Filter by priority") 60 + cmd.Flags().String("project", "", "Filter by project") 61 + 62 + return cmd 63 + } 64 + 65 + func viewTaskCmd(handler *handlers.TaskHandler) *cobra.Command { 66 + viewCmd := &cobra.Command{ 67 + Use: "view [task-id]", 68 + Short: "View task by ID", 69 + Args: cobra.ExactArgs(1), 70 + RunE: func(cmd *cobra.Command, args []string) error { 71 + format, _ := cmd.Flags().GetString("format") 72 + jsonOutput, _ := cmd.Flags().GetBool("json") 73 + noMetadata, _ := cmd.Flags().GetBool("no-metadata") 74 + 75 + defer handler.Close() 76 + return handler.View(cmd.Context(), args, format, jsonOutput, noMetadata) 77 + }, 78 + } 79 + viewCmd.Flags().String("format", "detailed", "Output format (detailed, brief)") 80 + viewCmd.Flags().Bool("json", false, "Output as JSON") 81 + viewCmd.Flags().Bool("no-metadata", false, "Hide creation/modification timestamps") 82 + 83 + return viewCmd 84 + } 85 + 86 + func updateTaskCmd(handler *handlers.TaskHandler) *cobra.Command { 87 + updateCmd := &cobra.Command{ 88 + Use: "update [task-id]", 89 + Short: "Update task properties", 90 + Args: cobra.ExactArgs(1), 91 + RunE: func(cmd *cobra.Command, args []string) error { 92 + taskID := args[0] 93 + description, _ := cmd.Flags().GetString("description") 94 + status, _ := cmd.Flags().GetString("status") 95 + priority, _ := cmd.Flags().GetString("priority") 96 + project, _ := cmd.Flags().GetString("project") 97 + due, _ := cmd.Flags().GetString("due") 98 + addTags, _ := cmd.Flags().GetStringSlice("add-tag") 99 + removeTags, _ := cmd.Flags().GetStringSlice("remove-tag") 100 + 101 + defer handler.Close() 102 + return handler.Update(cmd.Context(), taskID, description, status, priority, project, due, addTags, removeTags) 103 + }, 104 + } 105 + updateCmd.Flags().String("description", "", "Update task description") 106 + updateCmd.Flags().String("status", "", "Update task status") 107 + updateCmd.Flags().StringP("priority", "p", "", "Update task priority") 108 + updateCmd.Flags().String("project", "", "Update task project") 109 + updateCmd.Flags().StringP("due", "d", "", "Update due date (YYYY-MM-DD)") 110 + updateCmd.Flags().StringSlice("add-tag", []string{}, "Add tags to task") 111 + updateCmd.Flags().StringSlice("remove-tag", []string{}, "Remove tags from task") 112 + 113 + return updateCmd 114 + } 115 + 116 + func taskProjectsCmd(h *handlers.TaskHandler) *cobra.Command { 117 + cmd := &cobra.Command{ 118 + Use: "projects", 119 + Short: "List projects", 120 + Aliases: []string{"proj"}, 121 + RunE: func(c *cobra.Command, args []string) error { 122 + static, _ := c.Flags().GetBool("static") 123 + 124 + defer h.Close() 125 + return h.ListProjects(c.Context(), static) 126 + }, 127 + } 128 + cmd.Flags().Bool("static", false, "Use static text output instead of interactive") 129 + 130 + return cmd 131 + } 132 + 133 + func taskTagsCmd(h *handlers.TaskHandler) *cobra.Command { 134 + cmd := &cobra.Command{ 135 + Use: "tags", 136 + Short: "List tags", 137 + Aliases: []string{"t"}, 138 + RunE: func(c *cobra.Command, args []string) error { 139 + static, _ := c.Flags().GetBool("static") 140 + defer h.Close() 141 + return h.ListTags(c.Context(), static) 142 + }, 143 + } 144 + cmd.Flags().Bool("static", false, "Use static text output instead of interactive") 145 + return cmd 146 + } 147 + 148 + func taskStartCmd(h *handlers.TaskHandler) *cobra.Command { 149 + cmd := &cobra.Command{ 150 + Use: "start [task-id]", 151 + Short: "Start time tracking for a task", 152 + Args: cobra.ExactArgs(1), 153 + RunE: func(c *cobra.Command, args []string) error { 154 + taskID := args[0] 155 + description, _ := c.Flags().GetString("note") 156 + 157 + defer h.Close() 158 + return h.Start(c.Context(), taskID, description) 159 + }, 160 + } 161 + cmd.Flags().StringP("note", "n", "", "Add a note to the time entry") 162 + return cmd 163 + } 164 + 165 + func taskStopCmd(h *handlers.TaskHandler) *cobra.Command { 166 + return &cobra.Command{ 167 + Use: "stop [task-id]", 168 + Short: "Stop time tracking for a task", 169 + Args: cobra.ExactArgs(1), 170 + RunE: func(c *cobra.Command, args []string) error { 171 + taskID := args[0] 172 + defer h.Close() 173 + return h.Stop(c.Context(), taskID) 174 + }, 175 + } 176 + } 177 + 178 + func timesheetViewCmd(h *handlers.TaskHandler) *cobra.Command { 179 + cmd := &cobra.Command{ 180 + Use: "timesheet", 181 + Short: "Show time tracking summary", 182 + Long: `Show time tracking summary for tasks. 183 + 184 + By default shows time entries for the last 7 days. 185 + Use --task to show timesheet for a specific task. 186 + Use --days to change the date range.`, 187 + RunE: func(c *cobra.Command, args []string) error { 188 + days, _ := c.Flags().GetInt("days") 189 + taskID, _ := c.Flags().GetString("task") 190 + 191 + defer h.Close() 192 + return h.Timesheet(c.Context(), days, taskID) 193 + }, 194 + } 195 + cmd.Flags().IntP("days", "d", 7, "Number of days to show in timesheet") 196 + cmd.Flags().StringP("task", "t", "", "Show timesheet for specific task ID") 197 + return cmd 198 + } 199 + 200 + func editTaskCmd(h *handlers.TaskHandler) *cobra.Command { 201 + return &cobra.Command{ 202 + Use: "edit [task-id]", 203 + Short: "Edit task interactively with status picker and priority toggle", 204 + Aliases: []string{"e"}, 205 + Args: cobra.ExactArgs(1), 206 + RunE: func(c *cobra.Command, args []string) error { 207 + taskID := args[0] 208 + defer h.Close() 209 + return h.EditInteractive(c.Context(), taskID) 210 + }, 211 + } 212 + } 213 + 214 + func deleteTaskCmd(h *handlers.TaskHandler) *cobra.Command { 215 + return &cobra.Command{ 216 + Use: "delete [task-id]", 217 + Short: "Delete a task", 218 + Args: cobra.ExactArgs(1), 219 + RunE: func(c *cobra.Command, args []string) error { 220 + defer h.Close() 221 + return h.Delete(c.Context(), args) 222 + }, 223 + } 224 + } 225 + 226 + func taskContextsCmd(*handlers.TaskHandler) *cobra.Command { 227 + return &cobra.Command{ 228 + Use: "contexts", 229 + Short: "List contexts (locations)", 230 + Aliases: []string{"loc", "ctx", "locations"}, 231 + RunE: func(c *cobra.Command, args []string) error { 232 + fmt.Println("Listing task contexts...") 233 + return nil 234 + }, 235 + } 236 + } 237 + 238 + func taskCompleteCmd(h *handlers.TaskHandler) *cobra.Command { 239 + return &cobra.Command{ 240 + Use: "done [task-id]", 241 + Short: "Mark task as completed", 242 + Aliases: []string{"complete"}, 243 + Args: cobra.ExactArgs(1), 244 + RunE: func(c *cobra.Command, args []string) error { 245 + defer h.Close() 246 + return h.Done(c.Context(), args) 247 + }, 248 + } 249 + }
+177 -1
internal/handlers/tasks.go
··· 306 306 return nil 307 307 } 308 308 309 + // Start starts time tracking for a task 310 + func (h *TaskHandler) Start(ctx context.Context, taskID string, description string) error { 311 + var task *models.Task 312 + var err error 313 + 314 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 315 + task, err = h.repos.Tasks.Get(ctx, id) 316 + } else { 317 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 318 + } 319 + 320 + if err != nil { 321 + return fmt.Errorf("failed to find task: %w", err) 322 + } 323 + 324 + active, err := h.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) 325 + if err != nil && err.Error() != "sql: no rows in result set" { 326 + return fmt.Errorf("failed to check active time entry: %w", err) 327 + } 328 + if active != nil { 329 + duration := time.Since(active.StartTime) 330 + fmt.Printf("Task already started %s ago: %s\n", formatDuration(duration), task.Description) 331 + return nil 332 + } 333 + 334 + _, err = h.repos.TimeEntries.Start(ctx, task.ID, description) 335 + if err != nil { 336 + return fmt.Errorf("failed to start time tracking: %w", err) 337 + } 338 + 339 + fmt.Printf("Started task (ID: %d): %s\n", task.ID, task.Description) 340 + if description != "" { 341 + fmt.Printf("Note: %s\n", description) 342 + } 343 + 344 + return nil 345 + } 346 + 347 + // Stop stops time tracking for a task 348 + func (h *TaskHandler) Stop(ctx context.Context, taskID string) error { 349 + var task *models.Task 350 + var err error 351 + 352 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 353 + task, err = h.repos.Tasks.Get(ctx, id) 354 + } else { 355 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 356 + } 357 + 358 + if err != nil { 359 + return fmt.Errorf("failed to find task: %w", err) 360 + } 361 + 362 + entry, err := h.repos.TimeEntries.StopActiveByTaskID(ctx, task.ID) 363 + if err != nil { 364 + if err.Error() == "no active time entry found for task" { 365 + fmt.Printf("No active time tracking for task: %s\n", task.Description) 366 + return nil 367 + } 368 + return fmt.Errorf("failed to stop time tracking: %w", err) 369 + } 370 + 371 + fmt.Printf("Stopped task (ID: %d): %s\n", task.ID, task.Description) 372 + fmt.Printf("Time tracked: %s\n", formatDuration(entry.GetDuration())) 373 + 374 + return nil 375 + } 376 + 377 + // Timesheet shows time tracking summary 378 + func (h *TaskHandler) Timesheet(ctx context.Context, days int, taskID string) error { 379 + var entries []*models.TimeEntry 380 + var err error 381 + 382 + if taskID != "" { 383 + var task *models.Task 384 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 385 + task, err = h.repos.Tasks.Get(ctx, id) 386 + } else { 387 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 388 + } 389 + 390 + if err != nil { 391 + return fmt.Errorf("failed to find task: %w", err) 392 + } 393 + 394 + entries, err = h.repos.TimeEntries.GetByTaskID(ctx, task.ID) 395 + if err != nil { 396 + return fmt.Errorf("failed to get time entries: %w", err) 397 + } 398 + 399 + fmt.Printf("Timesheet for task: %s\n\n", task.Description) 400 + } else { 401 + end := time.Now() 402 + start := end.AddDate(0, 0, -days) 403 + 404 + entries, err = h.repos.TimeEntries.GetByDateRange(ctx, start, end) 405 + if err != nil { 406 + return fmt.Errorf("failed to get time entries: %w", err) 407 + } 408 + 409 + fmt.Printf("Timesheet for last %d days:\n\n", days) 410 + } 411 + 412 + if len(entries) == 0 { 413 + fmt.Printf("No time entries found\n") 414 + return nil 415 + } 416 + 417 + taskTotals := make(map[int64]time.Duration) 418 + dayTotals := make(map[string]time.Duration) 419 + totalTime := time.Duration(0) 420 + 421 + fmt.Printf("%-20s %-10s %-12s %-40s %s\n", "Date", "Duration", "Status", "Task", "Note") 422 + fmt.Printf("%s\n", strings.Repeat("-", 95)) 423 + 424 + for _, entry := range entries { 425 + task, err := h.repos.Tasks.Get(ctx, entry.TaskID) 426 + if err != nil { 427 + continue 428 + } 429 + 430 + duration := entry.GetDuration() 431 + day := entry.StartTime.Format("2006-01-02") 432 + status := "completed" 433 + if entry.IsActive() { 434 + status = "active" 435 + } 436 + 437 + taskTotals[entry.TaskID] += duration 438 + dayTotals[day] += duration 439 + totalTime += duration 440 + 441 + note := entry.Description 442 + if len(note) > 35 { 443 + note = note[:32] + "..." 444 + } 445 + 446 + taskDesc := task.Description 447 + if len(taskDesc) > 37 { 448 + taskDesc = taskDesc[:34] + "..." 449 + } 450 + 451 + fmt.Printf("%-20s %-10s %-12s %-40s %s\n", 452 + day, 453 + formatDuration(duration), 454 + status, 455 + fmt.Sprintf("[%d] %s", task.ID, taskDesc), 456 + note, 457 + ) 458 + } 459 + 460 + fmt.Printf("%s\n", strings.Repeat("-", 95)) 461 + fmt.Printf("Total time: %s\n", formatDuration(totalTime)) 462 + 463 + return nil 464 + } 465 + 309 466 // Done marks a task as completed 310 467 func (h *TaskHandler) Done(ctx context.Context, args []string) error { 311 468 if len(args) < 1 { ··· 349 506 if static { 350 507 return h.listProjectsStatic(ctx) 351 508 } 352 - 353 509 return h.listProjectsInteractive(ctx) 354 510 } 355 511 ··· 535 691 return "s" 536 692 } 537 693 } 694 + 695 + // formatDuration formats a duration in a human-readable format 696 + func formatDuration(d time.Duration) string { 697 + if d < time.Minute { 698 + return fmt.Sprintf("%.0fs", d.Seconds()) 699 + } 700 + if d < time.Hour { 701 + return fmt.Sprintf("%.0fm", d.Minutes()) 702 + } 703 + hours := d.Hours() 704 + if hours < 24 { 705 + return fmt.Sprintf("%.1fh", hours) 706 + } 707 + days := int(hours / 24) 708 + remainingHours := hours - float64(days*24) 709 + if remainingHours == 0 { 710 + return fmt.Sprintf("%dd", days) 711 + } 712 + return fmt.Sprintf("%dd %.1fh", days, remainingHours) 713 + }
+364
internal/handlers/time_tracking_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "strings" 8 + "testing" 9 + "time" 10 + 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + ) 13 + 14 + func setupTimeTrackingTestHandler(t *testing.T) (*TaskHandler, func()) { 15 + tempDir := t.TempDir() 16 + os.Setenv("NOTELEAF_CONFIG_DIR", tempDir) 17 + 18 + handler, err := NewTaskHandler() 19 + if err != nil { 20 + t.Fatalf("Failed to create test handler: %v", err) 21 + } 22 + 23 + cleanup := func() { 24 + handler.Close() 25 + os.Unsetenv("NOTELEAF_CONFIG_DIR") 26 + } 27 + 28 + return handler, cleanup 29 + } 30 + 31 + func createTimeTrackingTestTask(t *testing.T, handler *TaskHandler) *models.Task { 32 + ctx := context.Background() 33 + task := &models.Task{ 34 + UUID: fmt.Sprintf("test-time-uuid-%d", time.Now().UnixNano()), 35 + Description: "Test Time Tracking Task", 36 + Status: "pending", 37 + } 38 + 39 + id, err := handler.repos.Tasks.Create(ctx, task) 40 + if err != nil { 41 + t.Fatalf("Failed to create test task: %v", err) 42 + } 43 + task.ID = id 44 + return task 45 + } 46 + 47 + func TestTimeTracking(t *testing.T) { 48 + t.Run("Start", func(t *testing.T) { 49 + handler, cleanup := setupTimeTrackingTestHandler(t) 50 + defer cleanup() 51 + 52 + ctx := context.Background() 53 + task := createTimeTrackingTestTask(t, handler) 54 + 55 + t.Run("starts time tracking by ID", func(t *testing.T) { 56 + err := handler.Start(ctx, fmt.Sprintf("%d", task.ID), "Working on tests") 57 + 58 + if err != nil { 59 + t.Fatalf("Failed to start time tracking: %v", err) 60 + } 61 + 62 + active, err := handler.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) 63 + if err != nil { 64 + t.Fatalf("Failed to get active time entry: %v", err) 65 + } 66 + 67 + if active.Description != "Working on tests" { 68 + t.Errorf("Expected description 'Working on tests', got %q", active.Description) 69 + } 70 + if !active.IsActive() { 71 + t.Error("Expected time entry to be active") 72 + } 73 + }) 74 + 75 + t.Run("starts time tracking by UUID", func(t *testing.T) { 76 + err := handler.Stop(ctx, task.UUID) 77 + if err != nil { 78 + t.Fatalf("Failed to stop previous tracking: %v", err) 79 + } 80 + 81 + err = handler.Start(ctx, task.UUID, "Working via UUID") 82 + 83 + if err != nil { 84 + t.Fatalf("Failed to start time tracking by UUID: %v", err) 85 + } 86 + 87 + active, err := handler.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) 88 + if err != nil { 89 + t.Fatalf("Failed to get active time entry: %v", err) 90 + } 91 + 92 + if active.Description != "Working via UUID" { 93 + t.Errorf("Expected description 'Working via UUID', got %q", active.Description) 94 + } 95 + }) 96 + 97 + t.Run("handles already started task gracefully", func(t *testing.T) { 98 + err := handler.Start(ctx, fmt.Sprintf("%d", task.ID), "Another attempt") 99 + 100 + if err != nil { 101 + t.Fatalf("Expected graceful handling of already started task, got error: %v", err) 102 + } 103 + }) 104 + 105 + t.Run("fails with non-existent task", func(t *testing.T) { 106 + err := handler.Start(ctx, "99999", "Non-existent task") 107 + 108 + if err == nil { 109 + t.Error("Expected error for non-existent task") 110 + } 111 + if !strings.Contains(err.Error(), "failed to find task") { 112 + t.Errorf("Expected 'failed to find task' error, got: %v", err) 113 + } 114 + }) 115 + }) 116 + 117 + t.Run("Stop", func(t *testing.T) { 118 + handler, cleanup := setupTimeTrackingTestHandler(t) 119 + defer cleanup() 120 + 121 + ctx := context.Background() 122 + task := createTimeTrackingTestTask(t, handler) 123 + 124 + t.Run("stops active time tracking", func(t *testing.T) { 125 + err := handler.Start(ctx, fmt.Sprintf("%d", task.ID), "Test work") 126 + if err != nil { 127 + t.Fatalf("Failed to start time tracking: %v", err) 128 + } 129 + 130 + time.Sleep(1010 * time.Millisecond) 131 + 132 + err = handler.Stop(ctx, fmt.Sprintf("%d", task.ID)) 133 + 134 + if err != nil { 135 + t.Fatalf("Failed to stop time tracking: %v", err) 136 + } 137 + 138 + _, err = handler.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) 139 + if err.Error() != "sql: no rows in result set" { 140 + t.Errorf("Expected no active time entry after stopping, got: %v", err) 141 + } 142 + }) 143 + 144 + t.Run("handles no active tracking gracefully", func(t *testing.T) { 145 + err := handler.Stop(ctx, fmt.Sprintf("%d", task.ID)) 146 + 147 + if err != nil { 148 + t.Fatalf("Expected graceful handling of no active tracking, got error: %v", err) 149 + } 150 + }) 151 + 152 + t.Run("stops by UUID", func(t *testing.T) { 153 + err := handler.Start(ctx, task.UUID, "UUID test") 154 + if err != nil { 155 + t.Fatalf("Failed to start time tracking: %v", err) 156 + } 157 + 158 + time.Sleep(1010 * time.Millisecond) 159 + 160 + err = handler.Stop(ctx, task.UUID) 161 + 162 + if err != nil { 163 + t.Fatalf("Failed to stop time tracking by UUID: %v", err) 164 + } 165 + }) 166 + 167 + t.Run("fails with non-existent task", func(t *testing.T) { 168 + err := handler.Stop(ctx, "99999") 169 + 170 + if err == nil { 171 + t.Error("Expected error for non-existent task") 172 + } 173 + if !strings.Contains(err.Error(), "failed to find task") { 174 + t.Errorf("Expected 'failed to find task' error, got: %v", err) 175 + } 176 + }) 177 + }) 178 + 179 + t.Run("Timesheet", func(t *testing.T) { 180 + handler, cleanup := setupTimeTrackingTestHandler(t) 181 + defer cleanup() 182 + 183 + ctx := context.Background() 184 + task1 := createTimeTrackingTestTask(t, handler) 185 + 186 + task2 := &models.Task{ 187 + UUID: fmt.Sprintf("test-time-uuid-2-%d", time.Now().UnixNano()), 188 + Description: "Second Time Tracking Task", 189 + Status: "pending", 190 + } 191 + id2, err := handler.repos.Tasks.Create(ctx, task2) 192 + if err != nil { 193 + t.Fatalf("Failed to create second test task: %v", err) 194 + } 195 + task2.ID = id2 196 + 197 + setupTimeEntries := func() { 198 + entry1, _ := handler.repos.TimeEntries.Start(ctx, task1.ID, "First session") 199 + time.Sleep(1010 * time.Millisecond) 200 + handler.repos.TimeEntries.Stop(ctx, entry1.ID) 201 + 202 + entry2, _ := handler.repos.TimeEntries.Start(ctx, task2.ID, "Second task work") 203 + time.Sleep(1010 * time.Millisecond) 204 + handler.repos.TimeEntries.Stop(ctx, entry2.ID) 205 + 206 + handler.repos.TimeEntries.Start(ctx, task1.ID, "Active work") 207 + } 208 + 209 + t.Run("shows general timesheet", func(t *testing.T) { 210 + setupTimeEntries() 211 + 212 + err := handler.Timesheet(ctx, 7, "") 213 + 214 + if err != nil { 215 + t.Fatalf("Failed to generate timesheet: %v", err) 216 + } 217 + }) 218 + 219 + t.Run("shows task-specific timesheet", func(t *testing.T) { 220 + err := handler.Timesheet(ctx, 7, fmt.Sprintf("%d", task1.ID)) 221 + 222 + if err != nil { 223 + t.Fatalf("Failed to generate task timesheet: %v", err) 224 + } 225 + }) 226 + 227 + t.Run("shows task-specific timesheet by UUID", func(t *testing.T) { 228 + err := handler.Timesheet(ctx, 7, task1.UUID) 229 + 230 + if err != nil { 231 + t.Fatalf("Failed to generate task timesheet by UUID: %v", err) 232 + } 233 + }) 234 + 235 + t.Run("handles empty timesheet gracefully", func(t *testing.T) { 236 + task3 := &models.Task{ 237 + UUID: fmt.Sprintf("test-empty-uuid-%d", time.Now().UnixNano()), 238 + Description: "Empty Task", 239 + Status: "pending", 240 + } 241 + id3, err := handler.repos.Tasks.Create(ctx, task3) 242 + if err != nil { 243 + t.Fatalf("Failed to create empty test task: %v", err) 244 + } 245 + 246 + err = handler.Timesheet(ctx, 7, fmt.Sprintf("%d", id3)) 247 + 248 + if err != nil { 249 + t.Fatalf("Failed to handle empty timesheet: %v", err) 250 + } 251 + }) 252 + 253 + t.Run("fails with non-existent task", func(t *testing.T) { 254 + err := handler.Timesheet(ctx, 7, "99999") 255 + 256 + if err == nil { 257 + t.Error("Expected error for non-existent task") 258 + } 259 + if !strings.Contains(err.Error(), "failed to find task") { 260 + t.Errorf("Expected 'failed to find task' error, got: %v", err) 261 + } 262 + }) 263 + }) 264 + } 265 + 266 + func TestFormatDuration(t *testing.T) { 267 + tests := []struct { 268 + duration time.Duration 269 + expected string 270 + }{ 271 + {30 * time.Second, "30s"}, 272 + {90 * time.Second, "2m"}, 273 + {30 * time.Minute, "30m"}, 274 + {90 * time.Minute, "1.5h"}, 275 + {2 * time.Hour, "2.0h"}, 276 + {25 * time.Hour, "1d 1.0h"}, 277 + {48 * time.Hour, "2d"}, 278 + {72 * time.Hour, "3d"}, 279 + } 280 + 281 + for _, test := range tests { 282 + result := formatDuration(test.duration) 283 + if result != test.expected { 284 + t.Errorf("formatDuration(%v) = %q, expected %q", test.duration, result, test.expected) 285 + } 286 + } 287 + } 288 + 289 + func TestTimeEntryMethods(t *testing.T) { 290 + now := time.Now() 291 + 292 + t.Run("IsActive returns true for entry without end time", func(t *testing.T) { 293 + entry := &models.TimeEntry{ 294 + StartTime: now, 295 + EndTime: nil, 296 + } 297 + 298 + if !entry.IsActive() { 299 + t.Error("Expected entry to be active") 300 + } 301 + }) 302 + 303 + t.Run("IsActive returns false for entry with end time", func(t *testing.T) { 304 + endTime := now.Add(time.Hour) 305 + entry := &models.TimeEntry{ 306 + StartTime: now, 307 + EndTime: &endTime, 308 + } 309 + 310 + if entry.IsActive() { 311 + t.Error("Expected entry to not be active") 312 + } 313 + }) 314 + 315 + t.Run("Stop sets end time and calculates duration", func(t *testing.T) { 316 + entry := &models.TimeEntry{ 317 + StartTime: now.Add(-time.Second), // Start 1 second ago 318 + EndTime: nil, 319 + } 320 + 321 + entry.Stop() 322 + 323 + if entry.EndTime == nil { 324 + t.Error("Expected EndTime to be set after stopping") 325 + } 326 + if entry.DurationSeconds <= 0 { 327 + t.Error("Expected duration to be calculated and greater than 0") 328 + } 329 + if entry.IsActive() { 330 + t.Error("Expected entry to not be active after stopping") 331 + } 332 + }) 333 + 334 + t.Run("GetDuration returns calculated duration for completed entry", func(t *testing.T) { 335 + start := now 336 + end := now.Add(2 * time.Hour) 337 + entry := &models.TimeEntry{ 338 + StartTime: start, 339 + EndTime: &end, 340 + DurationSeconds: int64((2 * time.Hour).Seconds()), 341 + } 342 + 343 + duration := entry.GetDuration() 344 + expected := 2 * time.Hour 345 + 346 + if duration != expected { 347 + t.Errorf("Expected duration %v, got %v", expected, duration) 348 + } 349 + }) 350 + 351 + t.Run("GetDuration returns live duration for active entry", func(t *testing.T) { 352 + start := time.Now().Add(-time.Minute) 353 + entry := &models.TimeEntry{ 354 + StartTime: start, 355 + EndTime: nil, 356 + } 357 + 358 + duration := entry.GetDuration() 359 + 360 + if duration < 59*time.Second || duration > 61*time.Second { 361 + t.Errorf("Expected duration around 1 minute, got %v", duration) 362 + } 363 + }) 364 + }
+41
internal/models/models.go
··· 145 145 Modified time.Time `json:"modified"` 146 146 } 147 147 148 + // TimeEntry represents a time tracking entry for a task 149 + type TimeEntry struct { 150 + ID int64 `json:"id"` 151 + TaskID int64 `json:"task_id"` 152 + StartTime time.Time `json:"start_time"` 153 + EndTime *time.Time `json:"end_time,omitempty"` 154 + DurationSeconds int64 `json:"duration_seconds,omitempty"` 155 + Description string `json:"description,omitempty"` 156 + Created time.Time `json:"created"` 157 + Modified time.Time `json:"modified"` 158 + } 159 + 148 160 // MarshalTags converts tags slice to JSON string for database storage 149 161 func (t *Task) MarshalTags() (string, error) { 150 162 if len(t.Tags) == 0 { ··· 426 438 func (a *Album) SetCreatedAt(time time.Time) { a.Created = time } 427 439 func (a *Album) GetUpdatedAt() time.Time { return a.Modified } 428 440 func (a *Album) SetUpdatedAt(time time.Time) { a.Modified = time } 441 + 442 + // IsActive returns true if the time entry is currently active (not stopped) 443 + func (te *TimeEntry) IsActive() bool { 444 + return te.EndTime == nil 445 + } 446 + 447 + // Stop stops the time entry and calculates duration 448 + func (te *TimeEntry) Stop() { 449 + now := time.Now() 450 + te.EndTime = &now 451 + te.DurationSeconds = int64(now.Sub(te.StartTime).Seconds()) 452 + te.Modified = now 453 + } 454 + 455 + // GetDuration returns the duration of the time entry 456 + func (te *TimeEntry) GetDuration() time.Duration { 457 + if te.EndTime != nil { 458 + return time.Duration(te.DurationSeconds) * time.Second 459 + } 460 + return time.Since(te.StartTime) 461 + } 462 + 463 + func (te *TimeEntry) GetID() int64 { return te.ID } 464 + func (te *TimeEntry) SetID(id int64) { te.ID = id } 465 + func (te *TimeEntry) GetTableName() string { return "time_entries" } 466 + func (te *TimeEntry) GetCreatedAt() time.Time { return te.Created } 467 + func (te *TimeEntry) SetCreatedAt(time time.Time) { te.Created = time } 468 + func (te *TimeEntry) GetUpdatedAt() time.Time { return te.Modified } 469 + func (te *TimeEntry) SetUpdatedAt(time time.Time) { te.Modified = time }
+13 -13
internal/repo/repo.go
··· 1 1 package repo 2 2 3 - import ( 4 - "database/sql" 5 - ) 3 + import "database/sql" 6 4 7 5 // Repositories provides access to all resource repositories 8 6 type Repositories struct { 9 - Tasks *TaskRepository 10 - Movies *MovieRepository 11 - TV *TVRepository 12 - Books *BookRepository 13 - Notes *NoteRepository 7 + Tasks *TaskRepository 8 + Movies *MovieRepository 9 + TV *TVRepository 10 + Books *BookRepository 11 + Notes *NoteRepository 12 + TimeEntries *TimeEntryRepository 14 13 } 15 14 16 15 // NewRepositories creates a new set of repositories 17 16 func NewRepositories(db *sql.DB) *Repositories { 18 17 return &Repositories{ 19 - Tasks: NewTaskRepository(db), 20 - Movies: NewMovieRepository(db), 21 - TV: NewTVRepository(db), 22 - Books: NewBookRepository(db), 23 - Notes: NewNoteRepository(db), 18 + Tasks: NewTaskRepository(db), 19 + Movies: NewMovieRepository(db), 20 + TV: NewTVRepository(db), 21 + Books: NewBookRepository(db), 22 + Notes: NewNoteRepository(db), 23 + TimeEntries: NewTimeEntryRepository(db), 24 24 } 25 25 }
+294
internal/repo/time_entries.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/stormlightlabs/noteleaf/internal/models" 10 + ) 11 + 12 + // TimeEntryRepository provides database operations for time entries 13 + type TimeEntryRepository struct { 14 + db *sql.DB 15 + } 16 + 17 + // NewTimeEntryRepository creates a new time entry repository 18 + func NewTimeEntryRepository(db *sql.DB) *TimeEntryRepository { 19 + return &TimeEntryRepository{db: db} 20 + } 21 + 22 + // Start creates a new active time entry for a task 23 + func (r *TimeEntryRepository) Start(ctx context.Context, taskID int64, description string) (*models.TimeEntry, error) { 24 + active, err := r.GetActiveByTaskID(ctx, taskID) 25 + if err != nil && err != sql.ErrNoRows { 26 + return nil, fmt.Errorf("failed to check for active time entry: %w", err) 27 + } 28 + if active != nil { 29 + return nil, fmt.Errorf("task already has an active time entry") 30 + } 31 + 32 + now := time.Now() 33 + entry := &models.TimeEntry{ 34 + TaskID: taskID, 35 + StartTime: now, 36 + Description: description, 37 + Created: now, 38 + Modified: now, 39 + } 40 + 41 + query := ` 42 + INSERT INTO time_entries (task_id, start_time, description, created, modified) 43 + VALUES (?, ?, ?, ?, ?) 44 + ` 45 + 46 + result, err := r.db.ExecContext(ctx, query, entry.TaskID, entry.StartTime, entry.Description, entry.Created, entry.Modified) 47 + if err != nil { 48 + return nil, fmt.Errorf("failed to create time entry: %w", err) 49 + } 50 + 51 + id, err := result.LastInsertId() 52 + if err != nil { 53 + return nil, fmt.Errorf("failed to get time entry ID: %w", err) 54 + } 55 + 56 + entry.ID = id 57 + return entry, nil 58 + } 59 + 60 + // Stop stops an active time entry by ID 61 + func (r *TimeEntryRepository) Stop(ctx context.Context, id int64) (*models.TimeEntry, error) { 62 + entry, err := r.Get(ctx, id) 63 + if err != nil { 64 + return nil, fmt.Errorf("failed to get time entry: %w", err) 65 + } 66 + 67 + if !entry.IsActive() { 68 + return nil, fmt.Errorf("time entry is not active") 69 + } 70 + 71 + entry.Stop() 72 + 73 + query := ` 74 + UPDATE time_entries 75 + SET end_time = ?, duration_seconds = ?, modified = ? 76 + WHERE id = ? 77 + ` 78 + 79 + _, err = r.db.ExecContext(ctx, query, entry.EndTime, entry.DurationSeconds, entry.Modified, entry.ID) 80 + if err != nil { 81 + return nil, fmt.Errorf("failed to stop time entry: %w", err) 82 + } 83 + 84 + return entry, nil 85 + } 86 + 87 + // StopActiveByTaskID stops the active time entry for a task 88 + func (r *TimeEntryRepository) StopActiveByTaskID(ctx context.Context, taskID int64) (*models.TimeEntry, error) { 89 + active, err := r.GetActiveByTaskID(ctx, taskID) 90 + if err != nil { 91 + if err == sql.ErrNoRows { 92 + return nil, fmt.Errorf("no active time entry found for task") 93 + } 94 + return nil, fmt.Errorf("failed to get active time entry: %w", err) 95 + } 96 + 97 + return r.Stop(ctx, active.ID) 98 + } 99 + 100 + // Get retrieves a time entry by ID 101 + func (r *TimeEntryRepository) Get(ctx context.Context, id int64) (*models.TimeEntry, error) { 102 + query := ` 103 + SELECT id, task_id, start_time, end_time, duration_seconds, description, created, modified 104 + FROM time_entries 105 + WHERE id = ? 106 + ` 107 + 108 + entry := &models.TimeEntry{} 109 + var durationSeconds sql.NullInt64 110 + err := r.db.QueryRowContext(ctx, query, id).Scan( 111 + &entry.ID, 112 + &entry.TaskID, 113 + &entry.StartTime, 114 + &entry.EndTime, 115 + &durationSeconds, 116 + &entry.Description, 117 + &entry.Created, 118 + &entry.Modified, 119 + ) 120 + 121 + if durationSeconds.Valid { 122 + entry.DurationSeconds = durationSeconds.Int64 123 + } 124 + 125 + if err != nil { 126 + return nil, err 127 + } 128 + 129 + return entry, nil 130 + } 131 + 132 + // GetActiveByTaskID retrieves the active time entry for a task (if any) 133 + func (r *TimeEntryRepository) GetActiveByTaskID(ctx context.Context, taskID int64) (*models.TimeEntry, error) { 134 + query := ` 135 + SELECT id, task_id, start_time, end_time, duration_seconds, description, created, modified 136 + FROM time_entries 137 + WHERE task_id = ? AND end_time IS NULL 138 + ORDER BY start_time DESC 139 + LIMIT 1 140 + ` 141 + 142 + entry := &models.TimeEntry{} 143 + var durationSeconds sql.NullInt64 144 + err := r.db.QueryRowContext(ctx, query, taskID).Scan( 145 + &entry.ID, 146 + &entry.TaskID, 147 + &entry.StartTime, 148 + &entry.EndTime, 149 + &durationSeconds, 150 + &entry.Description, 151 + &entry.Created, 152 + &entry.Modified, 153 + ) 154 + 155 + if durationSeconds.Valid { 156 + entry.DurationSeconds = durationSeconds.Int64 157 + } 158 + 159 + if err != nil { 160 + return nil, err 161 + } 162 + 163 + return entry, nil 164 + } 165 + 166 + // GetByTaskID retrieves all time entries for a task 167 + func (r *TimeEntryRepository) GetByTaskID(ctx context.Context, taskID int64) ([]*models.TimeEntry, error) { 168 + query := ` 169 + SELECT id, task_id, start_time, end_time, duration_seconds, description, created, modified 170 + FROM time_entries 171 + WHERE task_id = ? 172 + ORDER BY start_time DESC 173 + ` 174 + 175 + rows, err := r.db.QueryContext(ctx, query, taskID) 176 + if err != nil { 177 + return nil, fmt.Errorf("failed to query time entries: %w", err) 178 + } 179 + defer rows.Close() 180 + 181 + var entries []*models.TimeEntry 182 + for rows.Next() { 183 + entry := &models.TimeEntry{} 184 + var durationSeconds sql.NullInt64 185 + err := rows.Scan( 186 + &entry.ID, 187 + &entry.TaskID, 188 + &entry.StartTime, 189 + &entry.EndTime, 190 + &durationSeconds, 191 + &entry.Description, 192 + &entry.Created, 193 + &entry.Modified, 194 + ) 195 + if err != nil { 196 + return nil, fmt.Errorf("failed to scan time entry: %w", err) 197 + } 198 + if durationSeconds.Valid { 199 + entry.DurationSeconds = durationSeconds.Int64 200 + } 201 + entries = append(entries, entry) 202 + } 203 + 204 + if err := rows.Err(); err != nil { 205 + return nil, fmt.Errorf("failed to iterate time entries: %w", err) 206 + } 207 + 208 + return entries, nil 209 + } 210 + 211 + // GetByDateRange retrieves time entries within a date range 212 + func (r *TimeEntryRepository) GetByDateRange(ctx context.Context, start, end time.Time) ([]*models.TimeEntry, error) { 213 + query := ` 214 + SELECT id, task_id, start_time, end_time, duration_seconds, description, created, modified 215 + FROM time_entries 216 + WHERE start_time >= ? AND start_time <= ? 217 + ORDER BY start_time DESC 218 + ` 219 + 220 + rows, err := r.db.QueryContext(ctx, query, start, end) 221 + if err != nil { 222 + return nil, fmt.Errorf("failed to query time entries by date range: %w", err) 223 + } 224 + defer rows.Close() 225 + 226 + var entries []*models.TimeEntry 227 + for rows.Next() { 228 + entry := &models.TimeEntry{} 229 + var durationSeconds sql.NullInt64 230 + 231 + if err := rows.Scan(&entry.ID, &entry.TaskID, &entry.StartTime, 232 + &entry.EndTime, &durationSeconds, &entry.Description, 233 + &entry.Created, &entry.Modified, 234 + ); err != nil { 235 + return nil, fmt.Errorf("failed to scan time entry: %w", err) 236 + } 237 + 238 + if durationSeconds.Valid { 239 + entry.DurationSeconds = durationSeconds.Int64 240 + } 241 + entries = append(entries, entry) 242 + } 243 + 244 + if err := rows.Err(); err != nil { 245 + return nil, fmt.Errorf("failed to iterate time entries: %w", err) 246 + } 247 + 248 + return entries, nil 249 + } 250 + 251 + // GetTotalTimeByTaskID calculates total time spent on a task 252 + func (r *TimeEntryRepository) GetTotalTimeByTaskID(ctx context.Context, taskID int64) (time.Duration, error) { 253 + query := ` 254 + SELECT COALESCE(SUM( 255 + CASE 256 + WHEN end_time IS NULL THEN 257 + (strftime('%s', 'now') - strftime('%s', start_time)) 258 + ELSE 259 + duration_seconds 260 + END 261 + ), 0) as total_seconds 262 + FROM time_entries 263 + WHERE task_id = ? 264 + ` 265 + 266 + var totalSeconds int64 267 + err := r.db.QueryRowContext(ctx, query, taskID).Scan(&totalSeconds) 268 + if err != nil { 269 + return 0, fmt.Errorf("failed to get total time: %w", err) 270 + } 271 + 272 + return time.Duration(totalSeconds) * time.Second, nil 273 + } 274 + 275 + // Delete removes a time entry 276 + func (r *TimeEntryRepository) Delete(ctx context.Context, id int64) error { 277 + query := `DELETE FROM time_entries WHERE id = ?` 278 + 279 + result, err := r.db.ExecContext(ctx, query, id) 280 + if err != nil { 281 + return fmt.Errorf("failed to delete time entry: %w", err) 282 + } 283 + 284 + rowsAffected, err := result.RowsAffected() 285 + if err != nil { 286 + return fmt.Errorf("failed to get rows affected: %w", err) 287 + } 288 + 289 + if rowsAffected == 0 { 290 + return fmt.Errorf("time entry not found") 291 + } 292 + 293 + return nil 294 + }
+356
internal/repo/time_entries_test.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "os" 8 + "testing" 9 + "time" 10 + 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + "github.com/stormlightlabs/noteleaf/internal/store" 13 + ) 14 + 15 + func setupTimeEntryTestDB(t *testing.T) (*sql.DB, *TimeEntryRepository, *TaskRepository, func()) { 16 + os.Setenv("NOTELEAF_CONFIG_DIR", t.TempDir()) 17 + 18 + db, err := store.NewDatabase() 19 + if err != nil { 20 + t.Fatalf("Failed to create test database: %v", err) 21 + } 22 + 23 + timeRepo := NewTimeEntryRepository(db.DB) 24 + taskRepo := NewTaskRepository(db.DB) 25 + 26 + cleanup := func() { 27 + db.Close() 28 + os.Unsetenv("NOTELEAF_CONFIG_DIR") 29 + } 30 + 31 + return db.DB, timeRepo, taskRepo, cleanup 32 + } 33 + 34 + func createTestTask(t *testing.T, taskRepo *TaskRepository) *models.Task { 35 + ctx := context.Background() 36 + task := &models.Task{ 37 + UUID: fmt.Sprintf("test-uuid-%d", time.Now().UnixNano()), 38 + Description: "Test Task", 39 + Status: "pending", 40 + } 41 + 42 + id, err := taskRepo.Create(ctx, task) 43 + if err != nil { 44 + t.Fatalf("Failed to create test task: %v", err) 45 + } 46 + task.ID = id 47 + return task 48 + } 49 + 50 + func TestTimeEntryRepository_Start(t *testing.T) { 51 + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) 52 + defer cleanup() 53 + 54 + ctx := context.Background() 55 + task := createTestTask(t, taskRepo) 56 + 57 + t.Run("starts time tracking successfully", func(t *testing.T) { 58 + description := "Working on feature" 59 + entry, err := repo.Start(ctx, task.ID, description) 60 + 61 + if err != nil { 62 + t.Fatalf("Failed to start time tracking: %v", err) 63 + } 64 + 65 + if entry.ID == 0 { 66 + t.Error("Expected entry to have an ID") 67 + } 68 + if entry.TaskID != task.ID { 69 + t.Errorf("Expected TaskID %d, got %d", task.ID, entry.TaskID) 70 + } 71 + if entry.Description != description { 72 + t.Errorf("Expected description %q, got %q", description, entry.Description) 73 + } 74 + if entry.EndTime != nil { 75 + t.Error("Expected EndTime to be nil for active entry") 76 + } 77 + if !entry.IsActive() { 78 + t.Error("Expected entry to be active") 79 + } 80 + }) 81 + 82 + t.Run("prevents starting already active task", func(t *testing.T) { 83 + _, err := repo.Start(ctx, task.ID, "Another attempt") 84 + 85 + if err == nil { 86 + t.Error("Expected error when starting already active task") 87 + } 88 + if err.Error() != "task already has an active time entry" { 89 + t.Errorf("Expected specific error message, got: %v", err) 90 + } 91 + }) 92 + } 93 + 94 + func TestTimeEntryRepository_Stop(t *testing.T) { 95 + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) 96 + defer cleanup() 97 + 98 + ctx := context.Background() 99 + task := createTestTask(t, taskRepo) 100 + 101 + entry, err := repo.Start(ctx, task.ID, "Test work") 102 + if err != nil { 103 + t.Fatalf("Failed to start time tracking: %v", err) 104 + } 105 + 106 + time.Sleep(1010 * time.Millisecond) 107 + 108 + t.Run("stops active time entry", func(t *testing.T) { 109 + stoppedEntry, err := repo.Stop(ctx, entry.ID) 110 + 111 + if err != nil { 112 + t.Fatalf("Failed to stop time tracking: %v", err) 113 + } 114 + 115 + if stoppedEntry.EndTime == nil { 116 + t.Error("Expected EndTime to be set") 117 + } 118 + if stoppedEntry.DurationSeconds <= 0 { 119 + t.Error("Expected duration to be greater than 0") 120 + } 121 + if stoppedEntry.IsActive() { 122 + t.Error("Expected entry to not be active after stopping") 123 + } 124 + }) 125 + 126 + t.Run("fails to stop already stopped entry", func(t *testing.T) { 127 + _, err := repo.Stop(ctx, entry.ID) 128 + 129 + if err == nil { 130 + t.Error("Expected error when stopping already stopped entry") 131 + } 132 + if err.Error() != "time entry is not active" { 133 + t.Errorf("Expected specific error message, got: %v", err) 134 + } 135 + }) 136 + } 137 + 138 + func TestTimeEntryRepository_StopActiveByTaskID(t *testing.T) { 139 + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) 140 + defer cleanup() 141 + 142 + ctx := context.Background() 143 + task := createTestTask(t, taskRepo) 144 + 145 + t.Run("stops active entry by task ID", func(t *testing.T) { 146 + _, err := repo.Start(ctx, task.ID, "Test work") 147 + if err != nil { 148 + t.Fatalf("Failed to start time tracking: %v", err) 149 + } 150 + 151 + stoppedEntry, err := repo.StopActiveByTaskID(ctx, task.ID) 152 + 153 + if err != nil { 154 + t.Fatalf("Failed to stop time tracking by task ID: %v", err) 155 + } 156 + 157 + if stoppedEntry.EndTime == nil { 158 + t.Error("Expected EndTime to be set") 159 + } 160 + if stoppedEntry.IsActive() { 161 + t.Error("Expected entry to not be active") 162 + } 163 + }) 164 + 165 + t.Run("fails when no active entry exists", func(t *testing.T) { 166 + _, err := repo.StopActiveByTaskID(ctx, task.ID) 167 + 168 + if err == nil { 169 + t.Error("Expected error when no active entry exists") 170 + } 171 + if err.Error() != "no active time entry found for task" { 172 + t.Errorf("Expected specific error message, got: %v", err) 173 + } 174 + }) 175 + } 176 + 177 + func TestTimeEntryRepository_GetActiveByTaskID(t *testing.T) { 178 + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) 179 + defer cleanup() 180 + 181 + ctx := context.Background() 182 + task := createTestTask(t, taskRepo) 183 + 184 + t.Run("returns nil when no active entry exists", func(t *testing.T) { 185 + _, err := repo.GetActiveByTaskID(ctx, task.ID) 186 + 187 + if err != sql.ErrNoRows { 188 + t.Errorf("Expected sql.ErrNoRows, got: %v", err) 189 + } 190 + }) 191 + 192 + t.Run("returns active entry when one exists", func(t *testing.T) { 193 + startedEntry, err := repo.Start(ctx, task.ID, "Test work") 194 + if err != nil { 195 + t.Fatalf("Failed to start time tracking: %v", err) 196 + } 197 + 198 + activeEntry, err := repo.GetActiveByTaskID(ctx, task.ID) 199 + 200 + if err != nil { 201 + t.Fatalf("Failed to get active entry: %v", err) 202 + } 203 + 204 + if activeEntry.ID != startedEntry.ID { 205 + t.Errorf("Expected entry ID %d, got %d", startedEntry.ID, activeEntry.ID) 206 + } 207 + if !activeEntry.IsActive() { 208 + t.Error("Expected entry to be active") 209 + } 210 + }) 211 + } 212 + 213 + func TestTimeEntryRepository_GetByTaskID(t *testing.T) { 214 + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) 215 + defer cleanup() 216 + 217 + ctx := context.Background() 218 + task := createTestTask(t, taskRepo) 219 + 220 + t.Run("returns empty slice when no entries exist", func(t *testing.T) { 221 + entries, err := repo.GetByTaskID(ctx, task.ID) 222 + 223 + if err != nil { 224 + t.Fatalf("Failed to get entries: %v", err) 225 + } 226 + 227 + if len(entries) != 0 { 228 + t.Errorf("Expected 0 entries, got %d", len(entries)) 229 + } 230 + }) 231 + 232 + t.Run("returns all entries for task", func(t *testing.T) { 233 + _, err := repo.Start(ctx, task.ID, "First session") 234 + if err != nil { 235 + t.Fatalf("Failed to start first session: %v", err) 236 + } 237 + 238 + _, err = repo.StopActiveByTaskID(ctx, task.ID) 239 + if err != nil { 240 + t.Fatalf("Failed to stop first session: %v", err) 241 + } 242 + 243 + _, err = repo.Start(ctx, task.ID, "Second session") 244 + if err != nil { 245 + t.Fatalf("Failed to start second session: %v", err) 246 + } 247 + 248 + entries, err := repo.GetByTaskID(ctx, task.ID) 249 + 250 + if err != nil { 251 + t.Fatalf("Failed to get entries: %v", err) 252 + } 253 + 254 + if len(entries) != 2 { 255 + t.Errorf("Expected 2 entries, got %d", len(entries)) 256 + } 257 + 258 + if entries[0].Description != "Second session" { 259 + t.Errorf("Expected first entry to be 'Second session', got %q", entries[0].Description) 260 + } 261 + if entries[1].Description != "First session" { 262 + t.Errorf("Expected second entry to be 'First session', got %q", entries[1].Description) 263 + } 264 + }) 265 + } 266 + 267 + func TestTimeEntryRepository_GetTotalTimeByTaskID(t *testing.T) { 268 + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) 269 + defer cleanup() 270 + 271 + ctx := context.Background() 272 + task := createTestTask(t, taskRepo) 273 + 274 + t.Run("returns zero duration when no entries exist", func(t *testing.T) { 275 + duration, err := repo.GetTotalTimeByTaskID(ctx, task.ID) 276 + 277 + if err != nil { 278 + t.Fatalf("Failed to get total time: %v", err) 279 + } 280 + 281 + if duration != 0 { 282 + t.Errorf("Expected 0 duration, got %v", duration) 283 + } 284 + }) 285 + 286 + t.Run("calculates total time including active entries", func(t *testing.T) { 287 + entry1, err := repo.Start(ctx, task.ID, "Completed work") 288 + if err != nil { 289 + t.Fatalf("Failed to start first entry: %v", err) 290 + } 291 + 292 + time.Sleep(1010 * time.Millisecond) 293 + _, err = repo.Stop(ctx, entry1.ID) 294 + if err != nil { 295 + t.Fatalf("Failed to stop first entry: %v", err) 296 + } 297 + 298 + _, err = repo.Start(ctx, task.ID, "Active work") 299 + if err != nil { 300 + t.Fatalf("Failed to start second entry: %v", err) 301 + } 302 + 303 + time.Sleep(1010 * time.Millisecond) 304 + 305 + totalTime, err := repo.GetTotalTimeByTaskID(ctx, task.ID) 306 + 307 + if err != nil { 308 + t.Fatalf("Failed to get total time: %v", err) 309 + } 310 + 311 + if totalTime <= 0 { 312 + t.Error("Expected total time to be greater than 0") 313 + } 314 + 315 + if totalTime < 2*time.Second { 316 + t.Errorf("Expected total time to be at least 2s, got %v", totalTime) 317 + } 318 + }) 319 + } 320 + 321 + func TestTimeEntryRepository_Delete(t *testing.T) { 322 + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) 323 + defer cleanup() 324 + 325 + ctx := context.Background() 326 + task := createTestTask(t, taskRepo) 327 + 328 + t.Run("deletes existing entry", func(t *testing.T) { 329 + entry, err := repo.Start(ctx, task.ID, "To be deleted") 330 + if err != nil { 331 + t.Fatalf("Failed to create entry: %v", err) 332 + } 333 + 334 + err = repo.Delete(ctx, entry.ID) 335 + 336 + if err != nil { 337 + t.Fatalf("Failed to delete entry: %v", err) 338 + } 339 + 340 + _, err = repo.Get(ctx, entry.ID) 341 + if err != sql.ErrNoRows { 342 + t.Errorf("Expected entry to be deleted, but got: %v", err) 343 + } 344 + }) 345 + 346 + t.Run("fails to delete non-existent entry", func(t *testing.T) { 347 + err := repo.Delete(ctx, 99999) 348 + 349 + if err == nil { 350 + t.Error("Expected error when deleting non-existent entry") 351 + } 352 + if err.Error() != "time entry not found" { 353 + t.Errorf("Expected specific error message, got: %v", err) 354 + } 355 + }) 356 + }
+5
internal/store/sql/migrations/0004_create_time_tracking_table_down.sql
··· 1 + -- Drop time tracking table 2 + DROP INDEX IF EXISTS idx_time_entries_task_id; 3 + DROP INDEX IF EXISTS idx_time_entries_start_time; 4 + DROP INDEX IF EXISTS idx_time_entries_end_time; 5 + DROP TABLE IF EXISTS time_entries;
+16
internal/store/sql/migrations/0004_create_time_tracking_table_up.sql
··· 1 + -- Time tracking table for tasks 2 + CREATE TABLE IF NOT EXISTS time_entries ( 3 + id INTEGER PRIMARY KEY AUTOINCREMENT, 4 + task_id INTEGER NOT NULL, 5 + start_time DATETIME NOT NULL, 6 + end_time DATETIME, 7 + duration_seconds INTEGER, 8 + description TEXT, 9 + created DATETIME DEFAULT CURRENT_TIMESTAMP, 10 + modified DATETIME DEFAULT CURRENT_TIMESTAMP, 11 + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE 12 + ); 13 + 14 + CREATE INDEX IF NOT EXISTS idx_time_entries_task_id ON time_entries(task_id); 15 + CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time); 16 + CREATE INDEX IF NOT EXISTS idx_time_entries_end_time ON time_entries(end_time);