diff --git a/cmd/commands.go b/cmd/commands.go index bf8b6d6..42e6e4a 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -28,9 +28,9 @@ func rootCmd() *cobra.Command { Use: "noteleaf", Long: ui.Georgia.ColoredInViewport(), Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(c *cobra.Command, args []string) error { if len(args) == 0 { - return cmd.Help() + return c.Help() } output := strings.Join(args, " ") @@ -44,238 +44,39 @@ func rootCmd() *cobra.Command { root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) - return root } func todoCmd() *cobra.Command { - root := &cobra.Command{ - Use: "todo", - Aliases: []string{"task"}, - Short: "task management", - } - - addCmd := &cobra.Command{ - Use: "add [description]", - Short: "Add a new task", - Aliases: []string{"create", "new"}, - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - priority, _ := cmd.Flags().GetString("priority") - project, _ := cmd.Flags().GetString("project") - due, _ := cmd.Flags().GetString("due") - tags, _ := cmd.Flags().GetStringSlice("tags") - - handler, err := handlers.NewTaskHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.Create(cmd.Context(), args, priority, project, due, tags) - }, - } - addCmd.Flags().StringP("priority", "p", "", "Set task priority") - addCmd.Flags().String("project", "", "Set task project") - addCmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") - addCmd.Flags().StringSliceP("tags", "t", []string{}, "Add tags to task") - root.AddCommand(addCmd) - - listCmd := &cobra.Command{ - Use: "list", - Short: "List tasks", - Aliases: []string{"ls"}, - Long: `List tasks with optional filtering and display modes. - -By default, shows tasks in an interactive TaskWarrior-like interface. -Use --static to show a simple text list instead. -Use --all to show all tasks, otherwise only pending tasks are shown.`, - RunE: func(cmd *cobra.Command, args []string) error { - static, _ := cmd.Flags().GetBool("static") - showAll, _ := cmd.Flags().GetBool("all") - status, _ := cmd.Flags().GetString("status") - priority, _ := cmd.Flags().GetString("priority") - project, _ := cmd.Flags().GetString("project") - - handler, err := handlers.NewTaskHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.List(cmd.Context(), static, showAll, status, priority, project) - }, - } - listCmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)") - listCmd.Flags().Bool("static", false, "Use static text output instead of interactive") - listCmd.Flags().BoolP("all", "a", false, "Show all tasks (default: pending only)") - listCmd.Flags().String("status", "", "Filter by status") - listCmd.Flags().String("priority", "", "Filter by priority") - listCmd.Flags().String("project", "", "Filter by project") - root.AddCommand(listCmd) - - viewCmd := &cobra.Command{ - Use: "view [task-id]", - Short: "View task by ID", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - format, _ := cmd.Flags().GetString("format") - jsonOutput, _ := cmd.Flags().GetBool("json") - noMetadata, _ := cmd.Flags().GetBool("no-metadata") - - handler, err := handlers.NewTaskHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.View(cmd.Context(), args, format, jsonOutput, noMetadata) - }, - } - viewCmd.Flags().String("format", "detailed", "Output format (detailed, brief)") - viewCmd.Flags().Bool("json", false, "Output as JSON") - viewCmd.Flags().Bool("no-metadata", false, "Hide creation/modification timestamps") - root.AddCommand(viewCmd) - - updateCmd := &cobra.Command{ - Use: "update [task-id]", - Short: "Update task properties", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - taskID := args[0] - description, _ := cmd.Flags().GetString("description") - status, _ := cmd.Flags().GetString("status") - priority, _ := cmd.Flags().GetString("priority") - project, _ := cmd.Flags().GetString("project") - due, _ := cmd.Flags().GetString("due") - addTags, _ := cmd.Flags().GetStringSlice("add-tag") - removeTags, _ := cmd.Flags().GetStringSlice("remove-tag") - - handler, err := handlers.NewTaskHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.Update(cmd.Context(), taskID, description, status, priority, project, due, addTags, removeTags) - }, - } - updateCmd.Flags().String("description", "", "Update task description") - updateCmd.Flags().String("status", "", "Update task status") - updateCmd.Flags().StringP("priority", "p", "", "Update task priority") - updateCmd.Flags().String("project", "", "Update task project") - updateCmd.Flags().StringP("due", "d", "", "Update due date (YYYY-MM-DD)") - updateCmd.Flags().StringSlice("add-tag", []string{}, "Add tags to task") - updateCmd.Flags().StringSlice("remove-tag", []string{}, "Remove tags from task") - root.AddCommand(updateCmd) - - editCmd := &cobra.Command{ - Use: "edit [task-id]", - Short: "Edit task interactively with status picker and priority toggle", - Aliases: []string{"e"}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - taskID := args[0] - handler, err := handlers.NewTaskHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.EditInteractive(cmd.Context(), taskID) - }, - } - root.AddCommand(editCmd) + root := &cobra.Command{Use: "todo", Aliases: []string{"task"}, Short: "task management"} - root.AddCommand(&cobra.Command{ - Use: "delete [task-id]", - Short: "Delete a task", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - handler, err := handlers.NewTaskHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.Delete(cmd.Context(), args) - }, - }) - - projectsCmd := &cobra.Command{ - Use: "projects", - Short: "List projects", - Aliases: []string{"proj"}, - RunE: func(cmd *cobra.Command, args []string) error { - static, _ := cmd.Flags().GetBool("static") - handler, err := handlers.NewTaskHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.ListProjects(cmd.Context(), static) - }, + handler, err := handlers.NewTaskHandler() + if err != nil { + log.Fatalf("failed to create task handler: %v", err) } - projectsCmd.Flags().Bool("static", false, "Use static text output instead of interactive") - root.AddCommand(projectsCmd) - tagsCmd := &cobra.Command{ - Use: "tags", - Short: "List tags", - Aliases: []string{"t"}, - RunE: func(cmd *cobra.Command, args []string) error { - static, _ := cmd.Flags().GetBool("static") - handler, err := handlers.NewTaskHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.ListTags(cmd.Context(), static) - }, + for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ + addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, + deleteTaskCmd, taskProjectsCmd, taskTagsCmd, taskContextsCmd, + taskCompleteCmd, taskStartCmd, taskStopCmd, timesheetViewCmd, + } { + cmd := init(handler) + root.AddCommand(cmd) } - tagsCmd.Flags().Bool("static", false, "Use static text output instead of interactive") - root.AddCommand(tagsCmd) - - root.AddCommand(&cobra.Command{ - Use: "contexts", - Short: "List contexts (locations)", - Aliases: []string{"loc", "ctx", "locations"}, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Listing task contexts...") - return nil - }, - }) - - root.AddCommand(&cobra.Command{ - Use: "done [task-id]", - Short: "Mark task as completed", - Aliases: []string{"complete"}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - handler, err := handlers.NewTaskHandler() - if err != nil { - return err - } - defer handler.Close() - return handler.Done(cmd.Context(), args) - }, - }) return root } func mediaCmd() *cobra.Command { - root := &cobra.Command{ - Use: "media", - Short: "Manage media queues (books, movies, TV shows)", + cmd := &cobra.Command{Use: "media", Short: "Manage media queues (books, movies, TV shows)"} + for _, init := range []func() *cobra.Command{bookMediaCmd, movieMediaCmd, tvMediaCmd} { + cmd.AddCommand(init()) } - - root.AddCommand(bookMediaCmd()) - root.AddCommand(movieMediaCmd()) - root.AddCommand(tvMediaCmd()) - - return root + return cmd } func movieMediaCmd() *cobra.Command { - root := &cobra.Command{ - Use: "movie", - Short: "Manage movie watch queue", - } + root := &cobra.Command{Use: "movie", Short: "Manage movie watch queue"} root.AddCommand(&cobra.Command{ Use: "add [title]", @@ -323,10 +124,7 @@ func movieMediaCmd() *cobra.Command { } func tvMediaCmd() *cobra.Command { - root := &cobra.Command{ - Use: "tv", - Short: "Manage TV show watch queue", - } + root := &cobra.Command{Use: "tv", Short: "Manage TV show watch queue"} root.AddCommand(&cobra.Command{ Use: "add [title]", @@ -374,12 +172,8 @@ func tvMediaCmd() *cobra.Command { } func bookMediaCmd() *cobra.Command { - root := &cobra.Command{ - Use: "book", - Short: "Manage reading list", - } + root := &cobra.Command{Use: "book", Short: "Manage reading list"} - // book add - Search and add book to reading list addCmd := &cobra.Command{ Use: "add [search query...]", Short: "Search and add book to reading list", @@ -395,7 +189,6 @@ Use the -i flag for an interactive interface with navigation keys.`, addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection") root.AddCommand(addCmd) - // book list - Show reading queue with progress root.AddCommand(&cobra.Command{ Use: "list [--all|--reading|--finished|--queued]", Short: "Show reading queue with progress", @@ -404,7 +197,6 @@ Use the -i flag for an interactive interface with navigation keys.`, }, }) - // book reading - Mark book as currently reading (alias for update status) root.AddCommand(&cobra.Command{ Use: "reading ", Short: "Mark book as currently reading", @@ -414,7 +206,6 @@ Use the -i flag for an interactive interface with navigation keys.`, }, }) - // book finished - Mark book as completed root.AddCommand(&cobra.Command{ Use: "finished ", Short: "Mark book as completed", @@ -425,7 +216,6 @@ Use the -i flag for an interactive interface with navigation keys.`, }, }) - // book remove - Remove from reading list root.AddCommand(&cobra.Command{ Use: "remove ", Short: "Remove from reading list", @@ -436,7 +226,6 @@ Use the -i flag for an interactive interface with navigation keys.`, }, }) - // book progress - Update reading progress percentage root.AddCommand(&cobra.Command{ Use: "progress ", Short: "Update reading progress percentage (0-100)", @@ -446,7 +235,6 @@ Use the -i flag for an interactive interface with navigation keys.`, }, }) - // book update - Update book status root.AddCommand(&cobra.Command{ Use: "update ", Short: "Update book status (queued|reading|finished|removed)", @@ -460,10 +248,7 @@ Use the -i flag for an interactive interface with navigation keys.`, } func noteCmd() *cobra.Command { - root := &cobra.Command{ - Use: "note", - Short: "Manage notes", - } + root := &cobra.Command{Use: "note", Short: "Manage notes"} handler, err := handlers.NewNoteHandler() if err != nil { @@ -613,8 +398,8 @@ func setupCmd() *cobra.Command { root := &cobra.Command{ Use: "setup", Short: "Initialize and manage application setup", - RunE: func(cmd *cobra.Command, args []string) error { - return handlers.Setup(cmd.Context(), args) + RunE: func(c *cobra.Command, args []string) error { + return handlers.Setup(c.Context(), args) }, } @@ -622,9 +407,9 @@ func setupCmd() *cobra.Command { Use: "seed", Short: "Populate database with test data", Long: "Add sample tasks, books, and notes to the database for testing and demonstration purposes", - RunE: func(cmd *cobra.Command, args []string) error { - force, _ := cmd.Flags().GetBool("force") - return handler.Seed(cmd.Context(), force) + RunE: func(c *cobra.Command, args []string) error { + force, _ := c.Flags().GetBool("force") + return handler.Seed(c.Context(), force) }, } seedCmd.Flags().BoolP("force", "f", false, "Clear existing data and re-seed") @@ -638,7 +423,7 @@ func confCmd() *cobra.Command { Use: "config [key] [value]", Short: "Manage configuration", Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(c *cobra.Command, args []string) error { key, value := args[0], args[1] fmt.Printf("Setting config %s = %s\n", key, value) return nil diff --git a/cmd/task_commands.go b/cmd/task_commands.go new file mode 100644 index 0000000..6a4e936 --- /dev/null +++ b/cmd/task_commands.go @@ -0,0 +1,249 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/stormlightlabs/noteleaf/internal/handlers" +) + +func addTaskCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "add [description]", + Short: "Add a new task", + Aliases: []string{"create", "new"}, + Args: cobra.MinimumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + priority, _ := c.Flags().GetString("priority") + project, _ := c.Flags().GetString("project") + due, _ := c.Flags().GetString("due") + tags, _ := c.Flags().GetStringSlice("tags") + + defer h.Close() + return h.Create(c.Context(), args, priority, project, due, tags) + }, + } + cmd.Flags().StringP("priority", "p", "", "Set task priority") + cmd.Flags().String("project", "", "Set task project") + cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") + cmd.Flags().StringSliceP("tags", "t", []string{}, "Add tags to task") + + return cmd +} + +func listTaskCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List tasks", + Aliases: []string{"ls"}, + Long: `List tasks with optional filtering and display modes. + +By default, shows tasks in an interactive TaskWarrior-like interface. +Use --static to show a simple text list instead. +Use --all to show all tasks, otherwise only pending tasks are shown.`, + RunE: func(c *cobra.Command, args []string) error { + static, _ := c.Flags().GetBool("static") + showAll, _ := c.Flags().GetBool("all") + status, _ := c.Flags().GetString("status") + priority, _ := c.Flags().GetString("priority") + project, _ := c.Flags().GetString("project") + + defer h.Close() + return h.List(c.Context(), static, showAll, status, priority, project) + }, + } + cmd.Flags().BoolP("interactive", "i", false, "Force interactive mode (default)") + cmd.Flags().Bool("static", false, "Use static text output instead of interactive") + cmd.Flags().BoolP("all", "a", false, "Show all tasks (default: pending only)") + cmd.Flags().String("status", "", "Filter by status") + cmd.Flags().String("priority", "", "Filter by priority") + cmd.Flags().String("project", "", "Filter by project") + + return cmd +} + +func viewTaskCmd(handler *handlers.TaskHandler) *cobra.Command { + viewCmd := &cobra.Command{ + Use: "view [task-id]", + Short: "View task by ID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + format, _ := cmd.Flags().GetString("format") + jsonOutput, _ := cmd.Flags().GetBool("json") + noMetadata, _ := cmd.Flags().GetBool("no-metadata") + + defer handler.Close() + return handler.View(cmd.Context(), args, format, jsonOutput, noMetadata) + }, + } + viewCmd.Flags().String("format", "detailed", "Output format (detailed, brief)") + viewCmd.Flags().Bool("json", false, "Output as JSON") + viewCmd.Flags().Bool("no-metadata", false, "Hide creation/modification timestamps") + + return viewCmd +} + +func updateTaskCmd(handler *handlers.TaskHandler) *cobra.Command { + updateCmd := &cobra.Command{ + Use: "update [task-id]", + Short: "Update task properties", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + taskID := args[0] + description, _ := cmd.Flags().GetString("description") + status, _ := cmd.Flags().GetString("status") + priority, _ := cmd.Flags().GetString("priority") + project, _ := cmd.Flags().GetString("project") + due, _ := cmd.Flags().GetString("due") + addTags, _ := cmd.Flags().GetStringSlice("add-tag") + removeTags, _ := cmd.Flags().GetStringSlice("remove-tag") + + defer handler.Close() + return handler.Update(cmd.Context(), taskID, description, status, priority, project, due, addTags, removeTags) + }, + } + updateCmd.Flags().String("description", "", "Update task description") + updateCmd.Flags().String("status", "", "Update task status") + updateCmd.Flags().StringP("priority", "p", "", "Update task priority") + updateCmd.Flags().String("project", "", "Update task project") + updateCmd.Flags().StringP("due", "d", "", "Update due date (YYYY-MM-DD)") + updateCmd.Flags().StringSlice("add-tag", []string{}, "Add tags to task") + updateCmd.Flags().StringSlice("remove-tag", []string{}, "Remove tags from task") + + return updateCmd +} + +func taskProjectsCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "projects", + Short: "List projects", + Aliases: []string{"proj"}, + RunE: func(c *cobra.Command, args []string) error { + static, _ := c.Flags().GetBool("static") + + defer h.Close() + return h.ListProjects(c.Context(), static) + }, + } + cmd.Flags().Bool("static", false, "Use static text output instead of interactive") + + return cmd +} + +func taskTagsCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "tags", + Short: "List tags", + Aliases: []string{"t"}, + RunE: func(c *cobra.Command, args []string) error { + static, _ := c.Flags().GetBool("static") + defer h.Close() + return h.ListTags(c.Context(), static) + }, + } + cmd.Flags().Bool("static", false, "Use static text output instead of interactive") + return cmd +} + +func taskStartCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "start [task-id]", + Short: "Start time tracking for a task", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + taskID := args[0] + description, _ := c.Flags().GetString("note") + + defer h.Close() + return h.Start(c.Context(), taskID, description) + }, + } + cmd.Flags().StringP("note", "n", "", "Add a note to the time entry") + return cmd +} + +func taskStopCmd(h *handlers.TaskHandler) *cobra.Command { + return &cobra.Command{ + Use: "stop [task-id]", + Short: "Stop time tracking for a task", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + taskID := args[0] + defer h.Close() + return h.Stop(c.Context(), taskID) + }, + } +} + +func timesheetViewCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "timesheet", + Short: "Show time tracking summary", + Long: `Show time tracking summary for tasks. + +By default shows time entries for the last 7 days. +Use --task to show timesheet for a specific task. +Use --days to change the date range.`, + RunE: func(c *cobra.Command, args []string) error { + days, _ := c.Flags().GetInt("days") + taskID, _ := c.Flags().GetString("task") + + defer h.Close() + return h.Timesheet(c.Context(), days, taskID) + }, + } + cmd.Flags().IntP("days", "d", 7, "Number of days to show in timesheet") + cmd.Flags().StringP("task", "t", "", "Show timesheet for specific task ID") + return cmd +} + +func editTaskCmd(h *handlers.TaskHandler) *cobra.Command { + return &cobra.Command{ + Use: "edit [task-id]", + Short: "Edit task interactively with status picker and priority toggle", + Aliases: []string{"e"}, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + taskID := args[0] + defer h.Close() + return h.EditInteractive(c.Context(), taskID) + }, + } +} + +func deleteTaskCmd(h *handlers.TaskHandler) *cobra.Command { + return &cobra.Command{ + Use: "delete [task-id]", + Short: "Delete a task", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + defer h.Close() + return h.Delete(c.Context(), args) + }, + } +} + +func taskContextsCmd(*handlers.TaskHandler) *cobra.Command { + return &cobra.Command{ + Use: "contexts", + Short: "List contexts (locations)", + Aliases: []string{"loc", "ctx", "locations"}, + RunE: func(c *cobra.Command, args []string) error { + fmt.Println("Listing task contexts...") + return nil + }, + } +} + +func taskCompleteCmd(h *handlers.TaskHandler) *cobra.Command { + return &cobra.Command{ + Use: "done [task-id]", + Short: "Mark task as completed", + Aliases: []string{"complete"}, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + defer h.Close() + return h.Done(c.Context(), args) + }, + } +} diff --git a/internal/handlers/tasks.go b/internal/handlers/tasks.go index 7000fa5..d155a51 100644 --- a/internal/handlers/tasks.go +++ b/internal/handlers/tasks.go @@ -306,6 +306,163 @@ func (h *TaskHandler) View(ctx context.Context, args []string, format string, js return nil } +// Start starts time tracking for a task +func (h *TaskHandler) Start(ctx context.Context, taskID string, description string) error { + var task *models.Task + var err error + + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { + task, err = h.repos.Tasks.Get(ctx, id) + } else { + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) + } + + if err != nil { + return fmt.Errorf("failed to find task: %w", err) + } + + active, err := h.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) + if err != nil && err.Error() != "sql: no rows in result set" { + return fmt.Errorf("failed to check active time entry: %w", err) + } + if active != nil { + duration := time.Since(active.StartTime) + fmt.Printf("Task already started %s ago: %s\n", formatDuration(duration), task.Description) + return nil + } + + _, err = h.repos.TimeEntries.Start(ctx, task.ID, description) + if err != nil { + return fmt.Errorf("failed to start time tracking: %w", err) + } + + fmt.Printf("Started task (ID: %d): %s\n", task.ID, task.Description) + if description != "" { + fmt.Printf("Note: %s\n", description) + } + + return nil +} + +// Stop stops time tracking for a task +func (h *TaskHandler) Stop(ctx context.Context, taskID string) error { + var task *models.Task + var err error + + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { + task, err = h.repos.Tasks.Get(ctx, id) + } else { + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) + } + + if err != nil { + return fmt.Errorf("failed to find task: %w", err) + } + + entry, err := h.repos.TimeEntries.StopActiveByTaskID(ctx, task.ID) + if err != nil { + if err.Error() == "no active time entry found for task" { + fmt.Printf("No active time tracking for task: %s\n", task.Description) + return nil + } + return fmt.Errorf("failed to stop time tracking: %w", err) + } + + fmt.Printf("Stopped task (ID: %d): %s\n", task.ID, task.Description) + fmt.Printf("Time tracked: %s\n", formatDuration(entry.GetDuration())) + + return nil +} + +// Timesheet shows time tracking summary +func (h *TaskHandler) Timesheet(ctx context.Context, days int, taskID string) error { + var entries []*models.TimeEntry + var err error + + if taskID != "" { + var task *models.Task + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { + task, err = h.repos.Tasks.Get(ctx, id) + } else { + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) + } + + if err != nil { + return fmt.Errorf("failed to find task: %w", err) + } + + entries, err = h.repos.TimeEntries.GetByTaskID(ctx, task.ID) + if err != nil { + return fmt.Errorf("failed to get time entries: %w", err) + } + + fmt.Printf("Timesheet for task: %s\n\n", task.Description) + } else { + end := time.Now() + start := end.AddDate(0, 0, -days) + + entries, err = h.repos.TimeEntries.GetByDateRange(ctx, start, end) + if err != nil { + return fmt.Errorf("failed to get time entries: %w", err) + } + + fmt.Printf("Timesheet for last %d days:\n\n", days) + } + + if len(entries) == 0 { + fmt.Printf("No time entries found\n") + return nil + } + + taskTotals := make(map[int64]time.Duration) + dayTotals := make(map[string]time.Duration) + totalTime := time.Duration(0) + + fmt.Printf("%-20s %-10s %-12s %-40s %s\n", "Date", "Duration", "Status", "Task", "Note") + fmt.Printf("%s\n", strings.Repeat("-", 95)) + + for _, entry := range entries { + task, err := h.repos.Tasks.Get(ctx, entry.TaskID) + if err != nil { + continue + } + + duration := entry.GetDuration() + day := entry.StartTime.Format("2006-01-02") + status := "completed" + if entry.IsActive() { + status = "active" + } + + taskTotals[entry.TaskID] += duration + dayTotals[day] += duration + totalTime += duration + + note := entry.Description + if len(note) > 35 { + note = note[:32] + "..." + } + + taskDesc := task.Description + if len(taskDesc) > 37 { + taskDesc = taskDesc[:34] + "..." + } + + fmt.Printf("%-20s %-10s %-12s %-40s %s\n", + day, + formatDuration(duration), + status, + fmt.Sprintf("[%d] %s", task.ID, taskDesc), + note, + ) + } + + fmt.Printf("%s\n", strings.Repeat("-", 95)) + fmt.Printf("Total time: %s\n", formatDuration(totalTime)) + + return nil +} + // Done marks a task as completed func (h *TaskHandler) Done(ctx context.Context, args []string) error { if len(args) < 1 { @@ -349,7 +506,6 @@ func (h *TaskHandler) ListProjects(ctx context.Context, static bool) error { if static { return h.listProjectsStatic(ctx) } - return h.listProjectsInteractive(ctx) } @@ -535,3 +691,23 @@ func pluralize(count int) string { return "s" } } + +// formatDuration formats a duration in a human-readable format +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%.0fs", d.Seconds()) + } + if d < time.Hour { + return fmt.Sprintf("%.0fm", d.Minutes()) + } + hours := d.Hours() + if hours < 24 { + return fmt.Sprintf("%.1fh", hours) + } + days := int(hours / 24) + remainingHours := hours - float64(days*24) + if remainingHours == 0 { + return fmt.Sprintf("%dd", days) + } + return fmt.Sprintf("%dd %.1fh", days, remainingHours) +} diff --git a/internal/handlers/time_tracking_test.go b/internal/handlers/time_tracking_test.go new file mode 100644 index 0000000..852ebc6 --- /dev/null +++ b/internal/handlers/time_tracking_test.go @@ -0,0 +1,364 @@ +package handlers + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/stormlightlabs/noteleaf/internal/models" +) + +func setupTimeTrackingTestHandler(t *testing.T) (*TaskHandler, func()) { + tempDir := t.TempDir() + os.Setenv("NOTELEAF_CONFIG_DIR", tempDir) + + handler, err := NewTaskHandler() + if err != nil { + t.Fatalf("Failed to create test handler: %v", err) + } + + cleanup := func() { + handler.Close() + os.Unsetenv("NOTELEAF_CONFIG_DIR") + } + + return handler, cleanup +} + +func createTimeTrackingTestTask(t *testing.T, handler *TaskHandler) *models.Task { + ctx := context.Background() + task := &models.Task{ + UUID: fmt.Sprintf("test-time-uuid-%d", time.Now().UnixNano()), + Description: "Test Time Tracking Task", + Status: "pending", + } + + id, err := handler.repos.Tasks.Create(ctx, task) + if err != nil { + t.Fatalf("Failed to create test task: %v", err) + } + task.ID = id + return task +} + +func TestTimeTracking(t *testing.T) { + t.Run("Start", func(t *testing.T) { + handler, cleanup := setupTimeTrackingTestHandler(t) + defer cleanup() + + ctx := context.Background() + task := createTimeTrackingTestTask(t, handler) + + t.Run("starts time tracking by ID", func(t *testing.T) { + err := handler.Start(ctx, fmt.Sprintf("%d", task.ID), "Working on tests") + + if err != nil { + t.Fatalf("Failed to start time tracking: %v", err) + } + + active, err := handler.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) + if err != nil { + t.Fatalf("Failed to get active time entry: %v", err) + } + + if active.Description != "Working on tests" { + t.Errorf("Expected description 'Working on tests', got %q", active.Description) + } + if !active.IsActive() { + t.Error("Expected time entry to be active") + } + }) + + t.Run("starts time tracking by UUID", func(t *testing.T) { + err := handler.Stop(ctx, task.UUID) + if err != nil { + t.Fatalf("Failed to stop previous tracking: %v", err) + } + + err = handler.Start(ctx, task.UUID, "Working via UUID") + + if err != nil { + t.Fatalf("Failed to start time tracking by UUID: %v", err) + } + + active, err := handler.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) + if err != nil { + t.Fatalf("Failed to get active time entry: %v", err) + } + + if active.Description != "Working via UUID" { + t.Errorf("Expected description 'Working via UUID', got %q", active.Description) + } + }) + + t.Run("handles already started task gracefully", func(t *testing.T) { + err := handler.Start(ctx, fmt.Sprintf("%d", task.ID), "Another attempt") + + if err != nil { + t.Fatalf("Expected graceful handling of already started task, got error: %v", err) + } + }) + + t.Run("fails with non-existent task", func(t *testing.T) { + err := handler.Start(ctx, "99999", "Non-existent task") + + if err == nil { + t.Error("Expected error for non-existent task") + } + if !strings.Contains(err.Error(), "failed to find task") { + t.Errorf("Expected 'failed to find task' error, got: %v", err) + } + }) + }) + + t.Run("Stop", func(t *testing.T) { + handler, cleanup := setupTimeTrackingTestHandler(t) + defer cleanup() + + ctx := context.Background() + task := createTimeTrackingTestTask(t, handler) + + t.Run("stops active time tracking", func(t *testing.T) { + err := handler.Start(ctx, fmt.Sprintf("%d", task.ID), "Test work") + if err != nil { + t.Fatalf("Failed to start time tracking: %v", err) + } + + time.Sleep(1010 * time.Millisecond) + + err = handler.Stop(ctx, fmt.Sprintf("%d", task.ID)) + + if err != nil { + t.Fatalf("Failed to stop time tracking: %v", err) + } + + _, err = handler.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) + if err.Error() != "sql: no rows in result set" { + t.Errorf("Expected no active time entry after stopping, got: %v", err) + } + }) + + t.Run("handles no active tracking gracefully", func(t *testing.T) { + err := handler.Stop(ctx, fmt.Sprintf("%d", task.ID)) + + if err != nil { + t.Fatalf("Expected graceful handling of no active tracking, got error: %v", err) + } + }) + + t.Run("stops by UUID", func(t *testing.T) { + err := handler.Start(ctx, task.UUID, "UUID test") + if err != nil { + t.Fatalf("Failed to start time tracking: %v", err) + } + + time.Sleep(1010 * time.Millisecond) + + err = handler.Stop(ctx, task.UUID) + + if err != nil { + t.Fatalf("Failed to stop time tracking by UUID: %v", err) + } + }) + + t.Run("fails with non-existent task", func(t *testing.T) { + err := handler.Stop(ctx, "99999") + + if err == nil { + t.Error("Expected error for non-existent task") + } + if !strings.Contains(err.Error(), "failed to find task") { + t.Errorf("Expected 'failed to find task' error, got: %v", err) + } + }) + }) + + t.Run("Timesheet", func(t *testing.T) { + handler, cleanup := setupTimeTrackingTestHandler(t) + defer cleanup() + + ctx := context.Background() + task1 := createTimeTrackingTestTask(t, handler) + + task2 := &models.Task{ + UUID: fmt.Sprintf("test-time-uuid-2-%d", time.Now().UnixNano()), + Description: "Second Time Tracking Task", + Status: "pending", + } + id2, err := handler.repos.Tasks.Create(ctx, task2) + if err != nil { + t.Fatalf("Failed to create second test task: %v", err) + } + task2.ID = id2 + + setupTimeEntries := func() { + entry1, _ := handler.repos.TimeEntries.Start(ctx, task1.ID, "First session") + time.Sleep(1010 * time.Millisecond) + handler.repos.TimeEntries.Stop(ctx, entry1.ID) + + entry2, _ := handler.repos.TimeEntries.Start(ctx, task2.ID, "Second task work") + time.Sleep(1010 * time.Millisecond) + handler.repos.TimeEntries.Stop(ctx, entry2.ID) + + handler.repos.TimeEntries.Start(ctx, task1.ID, "Active work") + } + + t.Run("shows general timesheet", func(t *testing.T) { + setupTimeEntries() + + err := handler.Timesheet(ctx, 7, "") + + if err != nil { + t.Fatalf("Failed to generate timesheet: %v", err) + } + }) + + t.Run("shows task-specific timesheet", func(t *testing.T) { + err := handler.Timesheet(ctx, 7, fmt.Sprintf("%d", task1.ID)) + + if err != nil { + t.Fatalf("Failed to generate task timesheet: %v", err) + } + }) + + t.Run("shows task-specific timesheet by UUID", func(t *testing.T) { + err := handler.Timesheet(ctx, 7, task1.UUID) + + if err != nil { + t.Fatalf("Failed to generate task timesheet by UUID: %v", err) + } + }) + + t.Run("handles empty timesheet gracefully", func(t *testing.T) { + task3 := &models.Task{ + UUID: fmt.Sprintf("test-empty-uuid-%d", time.Now().UnixNano()), + Description: "Empty Task", + Status: "pending", + } + id3, err := handler.repos.Tasks.Create(ctx, task3) + if err != nil { + t.Fatalf("Failed to create empty test task: %v", err) + } + + err = handler.Timesheet(ctx, 7, fmt.Sprintf("%d", id3)) + + if err != nil { + t.Fatalf("Failed to handle empty timesheet: %v", err) + } + }) + + t.Run("fails with non-existent task", func(t *testing.T) { + err := handler.Timesheet(ctx, 7, "99999") + + if err == nil { + t.Error("Expected error for non-existent task") + } + if !strings.Contains(err.Error(), "failed to find task") { + t.Errorf("Expected 'failed to find task' error, got: %v", err) + } + }) + }) +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + duration time.Duration + expected string + }{ + {30 * time.Second, "30s"}, + {90 * time.Second, "2m"}, + {30 * time.Minute, "30m"}, + {90 * time.Minute, "1.5h"}, + {2 * time.Hour, "2.0h"}, + {25 * time.Hour, "1d 1.0h"}, + {48 * time.Hour, "2d"}, + {72 * time.Hour, "3d"}, + } + + for _, test := range tests { + result := formatDuration(test.duration) + if result != test.expected { + t.Errorf("formatDuration(%v) = %q, expected %q", test.duration, result, test.expected) + } + } +} + +func TestTimeEntryMethods(t *testing.T) { + now := time.Now() + + t.Run("IsActive returns true for entry without end time", func(t *testing.T) { + entry := &models.TimeEntry{ + StartTime: now, + EndTime: nil, + } + + if !entry.IsActive() { + t.Error("Expected entry to be active") + } + }) + + t.Run("IsActive returns false for entry with end time", func(t *testing.T) { + endTime := now.Add(time.Hour) + entry := &models.TimeEntry{ + StartTime: now, + EndTime: &endTime, + } + + if entry.IsActive() { + t.Error("Expected entry to not be active") + } + }) + + t.Run("Stop sets end time and calculates duration", func(t *testing.T) { + entry := &models.TimeEntry{ + StartTime: now.Add(-time.Second), // Start 1 second ago + EndTime: nil, + } + + entry.Stop() + + if entry.EndTime == nil { + t.Error("Expected EndTime to be set after stopping") + } + if entry.DurationSeconds <= 0 { + t.Error("Expected duration to be calculated and greater than 0") + } + if entry.IsActive() { + t.Error("Expected entry to not be active after stopping") + } + }) + + t.Run("GetDuration returns calculated duration for completed entry", func(t *testing.T) { + start := now + end := now.Add(2 * time.Hour) + entry := &models.TimeEntry{ + StartTime: start, + EndTime: &end, + DurationSeconds: int64((2 * time.Hour).Seconds()), + } + + duration := entry.GetDuration() + expected := 2 * time.Hour + + if duration != expected { + t.Errorf("Expected duration %v, got %v", expected, duration) + } + }) + + t.Run("GetDuration returns live duration for active entry", func(t *testing.T) { + start := time.Now().Add(-time.Minute) + entry := &models.TimeEntry{ + StartTime: start, + EndTime: nil, + } + + duration := entry.GetDuration() + + if duration < 59*time.Second || duration > 61*time.Second { + t.Errorf("Expected duration around 1 minute, got %v", duration) + } + }) +} diff --git a/internal/models/models.go b/internal/models/models.go index 94f68d4..70c2286 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -145,6 +145,18 @@ type Album struct { Modified time.Time `json:"modified"` } +// TimeEntry represents a time tracking entry for a task +type TimeEntry struct { + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` + DurationSeconds int64 `json:"duration_seconds,omitempty"` + Description string `json:"description,omitempty"` + Created time.Time `json:"created"` + Modified time.Time `json:"modified"` +} + // MarshalTags converts tags slice to JSON string for database storage func (t *Task) MarshalTags() (string, error) { if len(t.Tags) == 0 { @@ -426,3 +438,32 @@ func (a *Album) GetCreatedAt() time.Time { return a.Created } func (a *Album) SetCreatedAt(time time.Time) { a.Created = time } func (a *Album) GetUpdatedAt() time.Time { return a.Modified } func (a *Album) SetUpdatedAt(time time.Time) { a.Modified = time } + +// IsActive returns true if the time entry is currently active (not stopped) +func (te *TimeEntry) IsActive() bool { + return te.EndTime == nil +} + +// Stop stops the time entry and calculates duration +func (te *TimeEntry) Stop() { + now := time.Now() + te.EndTime = &now + te.DurationSeconds = int64(now.Sub(te.StartTime).Seconds()) + te.Modified = now +} + +// GetDuration returns the duration of the time entry +func (te *TimeEntry) GetDuration() time.Duration { + if te.EndTime != nil { + return time.Duration(te.DurationSeconds) * time.Second + } + return time.Since(te.StartTime) +} + +func (te *TimeEntry) GetID() int64 { return te.ID } +func (te *TimeEntry) SetID(id int64) { te.ID = id } +func (te *TimeEntry) GetTableName() string { return "time_entries" } +func (te *TimeEntry) GetCreatedAt() time.Time { return te.Created } +func (te *TimeEntry) SetCreatedAt(time time.Time) { te.Created = time } +func (te *TimeEntry) GetUpdatedAt() time.Time { return te.Modified } +func (te *TimeEntry) SetUpdatedAt(time time.Time) { te.Modified = time } diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 884e299..f945244 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -1,25 +1,25 @@ package repo -import ( - "database/sql" -) +import "database/sql" // Repositories provides access to all resource repositories type Repositories struct { - Tasks *TaskRepository - Movies *MovieRepository - TV *TVRepository - Books *BookRepository - Notes *NoteRepository + Tasks *TaskRepository + Movies *MovieRepository + TV *TVRepository + Books *BookRepository + Notes *NoteRepository + TimeEntries *TimeEntryRepository } // NewRepositories creates a new set of repositories func NewRepositories(db *sql.DB) *Repositories { return &Repositories{ - Tasks: NewTaskRepository(db), - Movies: NewMovieRepository(db), - TV: NewTVRepository(db), - Books: NewBookRepository(db), - Notes: NewNoteRepository(db), + Tasks: NewTaskRepository(db), + Movies: NewMovieRepository(db), + TV: NewTVRepository(db), + Books: NewBookRepository(db), + Notes: NewNoteRepository(db), + TimeEntries: NewTimeEntryRepository(db), } } diff --git a/internal/repo/time_entries.go b/internal/repo/time_entries.go new file mode 100644 index 0000000..b1880c9 --- /dev/null +++ b/internal/repo/time_entries.go @@ -0,0 +1,294 @@ +package repo + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/stormlightlabs/noteleaf/internal/models" +) + +// TimeEntryRepository provides database operations for time entries +type TimeEntryRepository struct { + db *sql.DB +} + +// NewTimeEntryRepository creates a new time entry repository +func NewTimeEntryRepository(db *sql.DB) *TimeEntryRepository { + return &TimeEntryRepository{db: db} +} + +// Start creates a new active time entry for a task +func (r *TimeEntryRepository) Start(ctx context.Context, taskID int64, description string) (*models.TimeEntry, error) { + active, err := r.GetActiveByTaskID(ctx, taskID) + if err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("failed to check for active time entry: %w", err) + } + if active != nil { + return nil, fmt.Errorf("task already has an active time entry") + } + + now := time.Now() + entry := &models.TimeEntry{ + TaskID: taskID, + StartTime: now, + Description: description, + Created: now, + Modified: now, + } + + query := ` + INSERT INTO time_entries (task_id, start_time, description, created, modified) + VALUES (?, ?, ?, ?, ?) + ` + + result, err := r.db.ExecContext(ctx, query, entry.TaskID, entry.StartTime, entry.Description, entry.Created, entry.Modified) + if err != nil { + return nil, fmt.Errorf("failed to create time entry: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("failed to get time entry ID: %w", err) + } + + entry.ID = id + return entry, nil +} + +// Stop stops an active time entry by ID +func (r *TimeEntryRepository) Stop(ctx context.Context, id int64) (*models.TimeEntry, error) { + entry, err := r.Get(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get time entry: %w", err) + } + + if !entry.IsActive() { + return nil, fmt.Errorf("time entry is not active") + } + + entry.Stop() + + query := ` + UPDATE time_entries + SET end_time = ?, duration_seconds = ?, modified = ? + WHERE id = ? + ` + + _, err = r.db.ExecContext(ctx, query, entry.EndTime, entry.DurationSeconds, entry.Modified, entry.ID) + if err != nil { + return nil, fmt.Errorf("failed to stop time entry: %w", err) + } + + return entry, nil +} + +// StopActiveByTaskID stops the active time entry for a task +func (r *TimeEntryRepository) StopActiveByTaskID(ctx context.Context, taskID int64) (*models.TimeEntry, error) { + active, err := r.GetActiveByTaskID(ctx, taskID) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no active time entry found for task") + } + return nil, fmt.Errorf("failed to get active time entry: %w", err) + } + + return r.Stop(ctx, active.ID) +} + +// Get retrieves a time entry by ID +func (r *TimeEntryRepository) Get(ctx context.Context, id int64) (*models.TimeEntry, error) { + query := ` + SELECT id, task_id, start_time, end_time, duration_seconds, description, created, modified + FROM time_entries + WHERE id = ? + ` + + entry := &models.TimeEntry{} + var durationSeconds sql.NullInt64 + err := r.db.QueryRowContext(ctx, query, id).Scan( + &entry.ID, + &entry.TaskID, + &entry.StartTime, + &entry.EndTime, + &durationSeconds, + &entry.Description, + &entry.Created, + &entry.Modified, + ) + + if durationSeconds.Valid { + entry.DurationSeconds = durationSeconds.Int64 + } + + if err != nil { + return nil, err + } + + return entry, nil +} + +// GetActiveByTaskID retrieves the active time entry for a task (if any) +func (r *TimeEntryRepository) GetActiveByTaskID(ctx context.Context, taskID int64) (*models.TimeEntry, error) { + query := ` + SELECT id, task_id, start_time, end_time, duration_seconds, description, created, modified + FROM time_entries + WHERE task_id = ? AND end_time IS NULL + ORDER BY start_time DESC + LIMIT 1 + ` + + entry := &models.TimeEntry{} + var durationSeconds sql.NullInt64 + err := r.db.QueryRowContext(ctx, query, taskID).Scan( + &entry.ID, + &entry.TaskID, + &entry.StartTime, + &entry.EndTime, + &durationSeconds, + &entry.Description, + &entry.Created, + &entry.Modified, + ) + + if durationSeconds.Valid { + entry.DurationSeconds = durationSeconds.Int64 + } + + if err != nil { + return nil, err + } + + return entry, nil +} + +// GetByTaskID retrieves all time entries for a task +func (r *TimeEntryRepository) GetByTaskID(ctx context.Context, taskID int64) ([]*models.TimeEntry, error) { + query := ` + SELECT id, task_id, start_time, end_time, duration_seconds, description, created, modified + FROM time_entries + WHERE task_id = ? + ORDER BY start_time DESC + ` + + rows, err := r.db.QueryContext(ctx, query, taskID) + if err != nil { + return nil, fmt.Errorf("failed to query time entries: %w", err) + } + defer rows.Close() + + var entries []*models.TimeEntry + for rows.Next() { + entry := &models.TimeEntry{} + var durationSeconds sql.NullInt64 + err := rows.Scan( + &entry.ID, + &entry.TaskID, + &entry.StartTime, + &entry.EndTime, + &durationSeconds, + &entry.Description, + &entry.Created, + &entry.Modified, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan time entry: %w", err) + } + if durationSeconds.Valid { + entry.DurationSeconds = durationSeconds.Int64 + } + entries = append(entries, entry) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate time entries: %w", err) + } + + return entries, nil +} + +// GetByDateRange retrieves time entries within a date range +func (r *TimeEntryRepository) GetByDateRange(ctx context.Context, start, end time.Time) ([]*models.TimeEntry, error) { + query := ` + SELECT id, task_id, start_time, end_time, duration_seconds, description, created, modified + FROM time_entries + WHERE start_time >= ? AND start_time <= ? + ORDER BY start_time DESC + ` + + rows, err := r.db.QueryContext(ctx, query, start, end) + if err != nil { + return nil, fmt.Errorf("failed to query time entries by date range: %w", err) + } + defer rows.Close() + + var entries []*models.TimeEntry + for rows.Next() { + entry := &models.TimeEntry{} + var durationSeconds sql.NullInt64 + + if err := rows.Scan(&entry.ID, &entry.TaskID, &entry.StartTime, + &entry.EndTime, &durationSeconds, &entry.Description, + &entry.Created, &entry.Modified, + ); err != nil { + return nil, fmt.Errorf("failed to scan time entry: %w", err) + } + + if durationSeconds.Valid { + entry.DurationSeconds = durationSeconds.Int64 + } + entries = append(entries, entry) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate time entries: %w", err) + } + + return entries, nil +} + +// GetTotalTimeByTaskID calculates total time spent on a task +func (r *TimeEntryRepository) GetTotalTimeByTaskID(ctx context.Context, taskID int64) (time.Duration, error) { + query := ` + SELECT COALESCE(SUM( + CASE + WHEN end_time IS NULL THEN + (strftime('%s', 'now') - strftime('%s', start_time)) + ELSE + duration_seconds + END + ), 0) as total_seconds + FROM time_entries + WHERE task_id = ? + ` + + var totalSeconds int64 + err := r.db.QueryRowContext(ctx, query, taskID).Scan(&totalSeconds) + if err != nil { + return 0, fmt.Errorf("failed to get total time: %w", err) + } + + return time.Duration(totalSeconds) * time.Second, nil +} + +// Delete removes a time entry +func (r *TimeEntryRepository) Delete(ctx context.Context, id int64) error { + query := `DELETE FROM time_entries WHERE id = ?` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("failed to delete time entry: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("time entry not found") + } + + return nil +} diff --git a/internal/repo/time_entries_test.go b/internal/repo/time_entries_test.go new file mode 100644 index 0000000..0857200 --- /dev/null +++ b/internal/repo/time_entries_test.go @@ -0,0 +1,356 @@ +package repo + +import ( + "context" + "database/sql" + "fmt" + "os" + "testing" + "time" + + "github.com/stormlightlabs/noteleaf/internal/models" + "github.com/stormlightlabs/noteleaf/internal/store" +) + +func setupTimeEntryTestDB(t *testing.T) (*sql.DB, *TimeEntryRepository, *TaskRepository, func()) { + os.Setenv("NOTELEAF_CONFIG_DIR", t.TempDir()) + + db, err := store.NewDatabase() + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + + timeRepo := NewTimeEntryRepository(db.DB) + taskRepo := NewTaskRepository(db.DB) + + cleanup := func() { + db.Close() + os.Unsetenv("NOTELEAF_CONFIG_DIR") + } + + return db.DB, timeRepo, taskRepo, cleanup +} + +func createTestTask(t *testing.T, taskRepo *TaskRepository) *models.Task { + ctx := context.Background() + task := &models.Task{ + UUID: fmt.Sprintf("test-uuid-%d", time.Now().UnixNano()), + Description: "Test Task", + Status: "pending", + } + + id, err := taskRepo.Create(ctx, task) + if err != nil { + t.Fatalf("Failed to create test task: %v", err) + } + task.ID = id + return task +} + +func TestTimeEntryRepository_Start(t *testing.T) { + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) + defer cleanup() + + ctx := context.Background() + task := createTestTask(t, taskRepo) + + t.Run("starts time tracking successfully", func(t *testing.T) { + description := "Working on feature" + entry, err := repo.Start(ctx, task.ID, description) + + if err != nil { + t.Fatalf("Failed to start time tracking: %v", err) + } + + if entry.ID == 0 { + t.Error("Expected entry to have an ID") + } + if entry.TaskID != task.ID { + t.Errorf("Expected TaskID %d, got %d", task.ID, entry.TaskID) + } + if entry.Description != description { + t.Errorf("Expected description %q, got %q", description, entry.Description) + } + if entry.EndTime != nil { + t.Error("Expected EndTime to be nil for active entry") + } + if !entry.IsActive() { + t.Error("Expected entry to be active") + } + }) + + t.Run("prevents starting already active task", func(t *testing.T) { + _, err := repo.Start(ctx, task.ID, "Another attempt") + + if err == nil { + t.Error("Expected error when starting already active task") + } + if err.Error() != "task already has an active time entry" { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} + +func TestTimeEntryRepository_Stop(t *testing.T) { + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) + defer cleanup() + + ctx := context.Background() + task := createTestTask(t, taskRepo) + + entry, err := repo.Start(ctx, task.ID, "Test work") + if err != nil { + t.Fatalf("Failed to start time tracking: %v", err) + } + + time.Sleep(1010 * time.Millisecond) + + t.Run("stops active time entry", func(t *testing.T) { + stoppedEntry, err := repo.Stop(ctx, entry.ID) + + if err != nil { + t.Fatalf("Failed to stop time tracking: %v", err) + } + + if stoppedEntry.EndTime == nil { + t.Error("Expected EndTime to be set") + } + if stoppedEntry.DurationSeconds <= 0 { + t.Error("Expected duration to be greater than 0") + } + if stoppedEntry.IsActive() { + t.Error("Expected entry to not be active after stopping") + } + }) + + t.Run("fails to stop already stopped entry", func(t *testing.T) { + _, err := repo.Stop(ctx, entry.ID) + + if err == nil { + t.Error("Expected error when stopping already stopped entry") + } + if err.Error() != "time entry is not active" { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} + +func TestTimeEntryRepository_StopActiveByTaskID(t *testing.T) { + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) + defer cleanup() + + ctx := context.Background() + task := createTestTask(t, taskRepo) + + t.Run("stops active entry by task ID", func(t *testing.T) { + _, err := repo.Start(ctx, task.ID, "Test work") + if err != nil { + t.Fatalf("Failed to start time tracking: %v", err) + } + + stoppedEntry, err := repo.StopActiveByTaskID(ctx, task.ID) + + if err != nil { + t.Fatalf("Failed to stop time tracking by task ID: %v", err) + } + + if stoppedEntry.EndTime == nil { + t.Error("Expected EndTime to be set") + } + if stoppedEntry.IsActive() { + t.Error("Expected entry to not be active") + } + }) + + t.Run("fails when no active entry exists", func(t *testing.T) { + _, err := repo.StopActiveByTaskID(ctx, task.ID) + + if err == nil { + t.Error("Expected error when no active entry exists") + } + if err.Error() != "no active time entry found for task" { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} + +func TestTimeEntryRepository_GetActiveByTaskID(t *testing.T) { + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) + defer cleanup() + + ctx := context.Background() + task := createTestTask(t, taskRepo) + + t.Run("returns nil when no active entry exists", func(t *testing.T) { + _, err := repo.GetActiveByTaskID(ctx, task.ID) + + if err != sql.ErrNoRows { + t.Errorf("Expected sql.ErrNoRows, got: %v", err) + } + }) + + t.Run("returns active entry when one exists", func(t *testing.T) { + startedEntry, err := repo.Start(ctx, task.ID, "Test work") + if err != nil { + t.Fatalf("Failed to start time tracking: %v", err) + } + + activeEntry, err := repo.GetActiveByTaskID(ctx, task.ID) + + if err != nil { + t.Fatalf("Failed to get active entry: %v", err) + } + + if activeEntry.ID != startedEntry.ID { + t.Errorf("Expected entry ID %d, got %d", startedEntry.ID, activeEntry.ID) + } + if !activeEntry.IsActive() { + t.Error("Expected entry to be active") + } + }) +} + +func TestTimeEntryRepository_GetByTaskID(t *testing.T) { + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) + defer cleanup() + + ctx := context.Background() + task := createTestTask(t, taskRepo) + + t.Run("returns empty slice when no entries exist", func(t *testing.T) { + entries, err := repo.GetByTaskID(ctx, task.ID) + + if err != nil { + t.Fatalf("Failed to get entries: %v", err) + } + + if len(entries) != 0 { + t.Errorf("Expected 0 entries, got %d", len(entries)) + } + }) + + t.Run("returns all entries for task", func(t *testing.T) { + _, err := repo.Start(ctx, task.ID, "First session") + if err != nil { + t.Fatalf("Failed to start first session: %v", err) + } + + _, err = repo.StopActiveByTaskID(ctx, task.ID) + if err != nil { + t.Fatalf("Failed to stop first session: %v", err) + } + + _, err = repo.Start(ctx, task.ID, "Second session") + if err != nil { + t.Fatalf("Failed to start second session: %v", err) + } + + entries, err := repo.GetByTaskID(ctx, task.ID) + + if err != nil { + t.Fatalf("Failed to get entries: %v", err) + } + + if len(entries) != 2 { + t.Errorf("Expected 2 entries, got %d", len(entries)) + } + + if entries[0].Description != "Second session" { + t.Errorf("Expected first entry to be 'Second session', got %q", entries[0].Description) + } + if entries[1].Description != "First session" { + t.Errorf("Expected second entry to be 'First session', got %q", entries[1].Description) + } + }) +} + +func TestTimeEntryRepository_GetTotalTimeByTaskID(t *testing.T) { + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) + defer cleanup() + + ctx := context.Background() + task := createTestTask(t, taskRepo) + + t.Run("returns zero duration when no entries exist", func(t *testing.T) { + duration, err := repo.GetTotalTimeByTaskID(ctx, task.ID) + + if err != nil { + t.Fatalf("Failed to get total time: %v", err) + } + + if duration != 0 { + t.Errorf("Expected 0 duration, got %v", duration) + } + }) + + t.Run("calculates total time including active entries", func(t *testing.T) { + entry1, err := repo.Start(ctx, task.ID, "Completed work") + if err != nil { + t.Fatalf("Failed to start first entry: %v", err) + } + + time.Sleep(1010 * time.Millisecond) + _, err = repo.Stop(ctx, entry1.ID) + if err != nil { + t.Fatalf("Failed to stop first entry: %v", err) + } + + _, err = repo.Start(ctx, task.ID, "Active work") + if err != nil { + t.Fatalf("Failed to start second entry: %v", err) + } + + time.Sleep(1010 * time.Millisecond) + + totalTime, err := repo.GetTotalTimeByTaskID(ctx, task.ID) + + if err != nil { + t.Fatalf("Failed to get total time: %v", err) + } + + if totalTime <= 0 { + t.Error("Expected total time to be greater than 0") + } + + if totalTime < 2*time.Second { + t.Errorf("Expected total time to be at least 2s, got %v", totalTime) + } + }) +} + +func TestTimeEntryRepository_Delete(t *testing.T) { + _, repo, taskRepo, cleanup := setupTimeEntryTestDB(t) + defer cleanup() + + ctx := context.Background() + task := createTestTask(t, taskRepo) + + t.Run("deletes existing entry", func(t *testing.T) { + entry, err := repo.Start(ctx, task.ID, "To be deleted") + if err != nil { + t.Fatalf("Failed to create entry: %v", err) + } + + err = repo.Delete(ctx, entry.ID) + + if err != nil { + t.Fatalf("Failed to delete entry: %v", err) + } + + _, err = repo.Get(ctx, entry.ID) + if err != sql.ErrNoRows { + t.Errorf("Expected entry to be deleted, but got: %v", err) + } + }) + + t.Run("fails to delete non-existent entry", func(t *testing.T) { + err := repo.Delete(ctx, 99999) + + if err == nil { + t.Error("Expected error when deleting non-existent entry") + } + if err.Error() != "time entry not found" { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} diff --git a/internal/store/sql/migrations/0004_create_time_tracking_table_down.sql b/internal/store/sql/migrations/0004_create_time_tracking_table_down.sql new file mode 100644 index 0000000..e7dd396 --- /dev/null +++ b/internal/store/sql/migrations/0004_create_time_tracking_table_down.sql @@ -0,0 +1,5 @@ +-- Drop time tracking table +DROP INDEX IF EXISTS idx_time_entries_task_id; +DROP INDEX IF EXISTS idx_time_entries_start_time; +DROP INDEX IF EXISTS idx_time_entries_end_time; +DROP TABLE IF EXISTS time_entries; \ No newline at end of file diff --git a/internal/store/sql/migrations/0004_create_time_tracking_table_up.sql b/internal/store/sql/migrations/0004_create_time_tracking_table_up.sql new file mode 100644 index 0000000..246daee --- /dev/null +++ b/internal/store/sql/migrations/0004_create_time_tracking_table_up.sql @@ -0,0 +1,16 @@ +-- Time tracking table for tasks +CREATE TABLE IF NOT EXISTS time_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + start_time DATETIME NOT NULL, + end_time DATETIME, + duration_seconds INTEGER, + description TEXT, + created DATETIME DEFAULT CURRENT_TIMESTAMP, + modified DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_time_entries_task_id ON time_entries(task_id); +CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time); +CREATE INDEX IF NOT EXISTS idx_time_entries_end_time ON time_entries(end_time); \ No newline at end of file