From 90de2b654fdc487cff0fc095f166e0fe38d69847 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 21 Dec 2025 23:14:29 -0700 Subject: [PATCH 1/8] Refactor cmd package structure and project detection Commands have been reorganized into subpackages (entries, history, setup, tracking) for better modularity. Project name detection logic is now centralized in internal/project with a new DetectConfiguredProject function, replacing the previous DetectProjectName usage throughout the codebase. --- cmd/{ => entries}/delete.go | 5 +++-- cmd/{ => entries}/edit.go | 27 ++++++++++++++------------- cmd/{ => entries}/manual.go | 2 +- cmd/{ => entries}/manual_test.go | 2 +- cmd/{ => history}/export.go | 2 +- cmd/{ => history}/log.go | 2 +- cmd/{ => history}/stats.go | 2 +- cmd/{ => setup}/init.go | 2 +- cmd/{ => setup}/init_test.go | 2 +- cmd/{ => tracking}/pause.go | 6 +----- cmd/{ => tracking}/resume.go | 6 +----- cmd/{ => tracking}/start.go | 25 ++----------------------- cmd/{ => tracking}/start_test.go | 11 ++++++----- cmd/{ => tracking}/status.go | 6 +----- cmd/{ => tracking}/stop.go | 6 +----- internal/project/detect.go | 19 +++++++++++++++++++ 16 files changed, 55 insertions(+), 70 deletions(-) rename cmd/{ => entries}/delete.go (97%) rename cmd/{ => entries}/edit.go (95%) rename cmd/{ => entries}/manual.go (99%) rename cmd/{ => entries}/manual_test.go (99%) rename cmd/{ => history}/export.go (99%) rename cmd/{ => history}/log.go (99%) rename cmd/{ => history}/stats.go (99%) rename cmd/{ => setup}/init.go (99%) rename cmd/{ => setup}/init_test.go (99%) rename cmd/{ => tracking}/pause.go (95%) rename cmd/{ => tracking}/resume.go (96%) rename cmd/{ => tracking}/start.go (70%) rename cmd/{ => tracking}/start_test.go (89%) rename cmd/{ => tracking}/status.go (95%) rename cmd/{ => tracking}/stop.go (95%) diff --git a/cmd/delete.go b/cmd/entries/delete.go similarity index 97% rename from cmd/delete.go rename to cmd/entries/delete.go index 411e17e..c0b7b3e 100644 --- a/cmd/delete.go +++ b/cmd/entries/delete.go @@ -1,9 +1,10 @@ -package cmd +package entries import ( "fmt" "os" + "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/DylanDevelops/tmpo/internal/ui" "github.com/manifoldco/promptui" @@ -59,7 +60,7 @@ var deleteCmd = &cobra.Command{ projectName = selectedProject } else { // Use current project - detectedProject, err := DetectProjectName() + detectedProject, err := project.DetectConfiguredProject() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) os.Exit(1) diff --git a/cmd/edit.go b/cmd/entries/edit.go similarity index 95% rename from cmd/edit.go rename to cmd/entries/edit.go index 75fef93..b055a11 100644 --- a/cmd/edit.go +++ b/cmd/entries/edit.go @@ -1,10 +1,11 @@ -package cmd +package entries import ( "fmt" "os" "strings" + "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/storage" "github.com/DylanDevelops/tmpo/internal/ui" "github.com/manifoldco/promptui" @@ -13,11 +14,12 @@ import ( var showAllProjects bool -var editCmd = &cobra.Command{ - Use: "edit", - Short: "Edit an existing time entry", - Long: `Edit an existing time entry using an interactive menu.`, - Run: func(cmd *cobra.Command, args []string) { +func EditCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "edit", + Short: "Edit an existing time entry", + Long: `Edit an existing time entry using an interactive menu.`, + Run: func(cmd *cobra.Command, args []string) { ui.NewlineAbove() ui.PrintSuccess("✏️", "Edit Time Entry") fmt.Println() @@ -60,7 +62,7 @@ var editCmd = &cobra.Command{ projectName = selectedProject } else { // Use current project - detectedProject, err := DetectProjectName() + detectedProject, err := project.DetectConfiguredProject() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) os.Exit(1) @@ -311,6 +313,11 @@ var editCmd = &cobra.Command{ ui.PrintSuccess(ui.EmojiSuccess, "Entry updated successfully") ui.NewlineBelow() }, + } + + cmd.Flags().BoolVar(&showAllProjects, "show-all-projects", false, "Show project selection before entry selection") + + return cmd } // formatEntryLabel formats a time entry for display in the selection list @@ -348,9 +355,3 @@ func validateTimeOptional(input string) error { } return validateTime(input) } - -func init() { - rootCmd.AddCommand(editCmd) - - editCmd.Flags().BoolVar(&showAllProjects, "show-all-projects", false, "Show project selection before entry selection") -} diff --git a/cmd/manual.go b/cmd/entries/manual.go similarity index 99% rename from cmd/manual.go rename to cmd/entries/manual.go index 40587f1..439f30a 100644 --- a/cmd/manual.go +++ b/cmd/entries/manual.go @@ -1,4 +1,4 @@ -package cmd +package entries import ( "fmt" diff --git a/cmd/manual_test.go b/cmd/entries/manual_test.go similarity index 99% rename from cmd/manual_test.go rename to cmd/entries/manual_test.go index 390588a..d2a3dab 100644 --- a/cmd/manual_test.go +++ b/cmd/entries/manual_test.go @@ -1,4 +1,4 @@ -package cmd +package entries import ( "testing" diff --git a/cmd/export.go b/cmd/history/export.go similarity index 99% rename from cmd/export.go rename to cmd/history/export.go index 6915492..2a99285 100644 --- a/cmd/export.go +++ b/cmd/history/export.go @@ -1,4 +1,4 @@ -package cmd +package history import ( "fmt" diff --git a/cmd/log.go b/cmd/history/log.go similarity index 99% rename from cmd/log.go rename to cmd/history/log.go index 7ee735a..d24f605 100644 --- a/cmd/log.go +++ b/cmd/history/log.go @@ -1,4 +1,4 @@ -package cmd +package history import ( "fmt" diff --git a/cmd/stats.go b/cmd/history/stats.go similarity index 99% rename from cmd/stats.go rename to cmd/history/stats.go index b848045..8c57ba8 100644 --- a/cmd/stats.go +++ b/cmd/history/stats.go @@ -1,4 +1,4 @@ -package cmd +package history import ( "fmt" diff --git a/cmd/init.go b/cmd/setup/init.go similarity index 99% rename from cmd/init.go rename to cmd/setup/init.go index 0864290..ddc9a90 100644 --- a/cmd/init.go +++ b/cmd/setup/init.go @@ -1,4 +1,4 @@ -package cmd +package setup import ( "fmt" diff --git a/cmd/init_test.go b/cmd/setup/init_test.go similarity index 99% rename from cmd/init_test.go rename to cmd/setup/init_test.go index 4b0948e..d9edf62 100644 --- a/cmd/init_test.go +++ b/cmd/setup/init_test.go @@ -1,4 +1,4 @@ -package cmd +package setup import ( "os" diff --git a/cmd/pause.go b/cmd/tracking/pause.go similarity index 95% rename from cmd/pause.go rename to cmd/tracking/pause.go index d5caabf..3d639b9 100644 --- a/cmd/pause.go +++ b/cmd/tracking/pause.go @@ -1,4 +1,4 @@ -package cmd +package tracking import ( "fmt" @@ -52,7 +52,3 @@ var pauseCmd = &cobra.Command{ ui.NewlineBelow() }, } - -func init() { - rootCmd.AddCommand(pauseCmd) -} diff --git a/cmd/resume.go b/cmd/tracking/resume.go similarity index 96% rename from cmd/resume.go rename to cmd/tracking/resume.go index 903b365..b81175a 100644 --- a/cmd/resume.go +++ b/cmd/tracking/resume.go @@ -1,4 +1,4 @@ -package cmd +package tracking import ( "fmt" @@ -65,7 +65,3 @@ var resumeCmd = &cobra.Command{ ui.NewlineBelow() }, } - -func init() { - rootCmd.AddCommand(resumeCmd) -} diff --git a/cmd/start.go b/cmd/tracking/start.go similarity index 70% rename from cmd/start.go rename to cmd/tracking/start.go index b641c7c..b397d4f 100644 --- a/cmd/start.go +++ b/cmd/tracking/start.go @@ -1,4 +1,4 @@ -package cmd +package tracking import ( "fmt" @@ -39,7 +39,7 @@ var startCmd = &cobra.Command{ os.Exit(1) } - projectName, err := DetectProjectName() + projectName, err := project.DetectConfiguredProject() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) os.Exit(1) @@ -79,24 +79,3 @@ var startCmd = &cobra.Command{ ui.NewlineBelow() }, } - -// DetectProjectName returns the name of the current project. -// It first attempts to load a configuration via config.FindAndLoad; if a configuration -// is found and its ProjectName field is non-empty, that value is returned. -// If no configuration or project name is available, DetectProjectName falls back to -// project.DetectProject() to determine the project name from the repository or environment. -// The function returns the determined project name and any error encountered during -// configuration loading or fallback detection. -func DetectProjectName() (string, error) { - if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { - if cfg.ProjectName != "" { - return cfg.ProjectName, nil - } - } - - return project.DetectProject() -} - -func init() { - rootCmd.AddCommand(startCmd) -} diff --git a/cmd/start_test.go b/cmd/tracking/start_test.go similarity index 89% rename from cmd/start_test.go rename to cmd/tracking/start_test.go index 5f233e5..6561570 100644 --- a/cmd/start_test.go +++ b/cmd/tracking/start_test.go @@ -1,4 +1,4 @@ -package cmd +package tracking import ( "os" @@ -6,6 +6,7 @@ import ( "testing" "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/project" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -30,7 +31,7 @@ func TestDetectProjectName(t *testing.T) { require.NoError(t, err) // Test - projectName, err := DetectProjectName() + projectName, err := project.DetectConfiguredProject() assert.NoError(t, err) assert.Equal(t, "test-project-from-config", projectName) }) @@ -50,7 +51,7 @@ func TestDetectProjectName(t *testing.T) { require.NoError(t, err) // Test - should use directory name as fallback since it's not a real git repo - projectName, err := DetectProjectName() + projectName, err := project.DetectConfiguredProject() assert.NoError(t, err) assert.NotEmpty(t, projectName) }) @@ -66,7 +67,7 @@ func TestDetectProjectName(t *testing.T) { require.NoError(t, err) // Test - projectName, err := DetectProjectName() + projectName, err := project.DetectConfiguredProject() assert.NoError(t, err) // Should use the directory name assert.NotEmpty(t, projectName) @@ -92,7 +93,7 @@ func TestDetectProjectName(t *testing.T) { require.NoError(t, err) // Test - projectName, err := DetectProjectName() + projectName, err := project.DetectConfiguredProject() assert.NoError(t, err) assert.NotEmpty(t, projectName) }) diff --git a/cmd/status.go b/cmd/tracking/status.go similarity index 95% rename from cmd/status.go rename to cmd/tracking/status.go index 8363438..f980791 100644 --- a/cmd/status.go +++ b/cmd/tracking/status.go @@ -1,4 +1,4 @@ -package cmd +package tracking import ( "fmt" @@ -53,7 +53,3 @@ var statusCmd = &cobra.Command{ ui.NewlineBelow() }, } - -func init() { - rootCmd.AddCommand(statusCmd) -} diff --git a/cmd/stop.go b/cmd/tracking/stop.go similarity index 95% rename from cmd/stop.go rename to cmd/tracking/stop.go index dc6cb40..21dc8a4 100644 --- a/cmd/stop.go +++ b/cmd/tracking/stop.go @@ -1,4 +1,4 @@ -package cmd +package tracking import ( "fmt" @@ -51,7 +51,3 @@ var stopCmd = &cobra.Command{ ui.NewlineBelow() }, } - -func init() { - rootCmd.AddCommand(stopCmd) -} diff --git a/internal/project/detect.go b/internal/project/detect.go index d6edd10..540ee56 100644 --- a/internal/project/detect.go +++ b/internal/project/detect.go @@ -6,6 +6,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/DylanDevelops/tmpo/internal/config" ) // DetectProject attempts to determine the project name using a prioritized strategy: @@ -41,6 +43,23 @@ func DetectProject() (string, error) { return filepath.Base(cwd), nil } + +// DetectConfiguredProject returns the project name specified in the repository +// configuration, if present, otherwise it falls back to auto-detection. +// It loads configuration via config.FindAndLoad(); if a non-empty cfg.ProjectName +// is found that value is returned with a nil error. If no configured project +// name exists or loading fails, DetectProject() is invoked and its result is +// returned (project name, error). +func DetectConfiguredProject() (string, error) { + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { + if cfg.ProjectName != "" { + return cfg.ProjectName, nil + } + } + + return DetectProject() +} + // FindTmporc searches the current working directory and each parent directory // up to the filesystem root for a file named ".tmporc". If found, it returns // the path to that file and a nil error. If no ".tmporc" file is found, it From 1c051ce07ca0226f0f79aa568dd81b4a54309dd4 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 21 Dec 2025 23:17:13 -0700 Subject: [PATCH 2/8] Refactor entry edit and manual commands to use functions Converted the edit and manual entry commands to return *cobra.Command from functions instead of using package-level variables. This improves modularity and testability, and aligns with best practices for Cobra command structure. --- cmd/entries/edit.go | 532 +++++++++++++++++++++--------------------- cmd/entries/manual.go | 326 +++++++++++++------------- 2 files changed, 429 insertions(+), 429 deletions(-) diff --git a/cmd/entries/edit.go b/cmd/entries/edit.go index b055a11..19509c9 100644 --- a/cmd/entries/edit.go +++ b/cmd/entries/edit.go @@ -20,299 +20,299 @@ func EditCmd() *cobra.Command { Short: "Edit an existing time entry", Long: `Edit an existing time entry using an interactive menu.`, Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - ui.PrintSuccess("✏️", "Edit Time Entry") - fmt.Println() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - defer db.Close() - - var entries []*storage.TimeEntry - var projectName string - - if showAllProjects { - // Show project selection first - projects, err := db.GetProjectsWithCompletedEntries() + ui.NewlineAbove() + ui.PrintSuccess("✏️", "Edit Time Entry") + fmt.Println() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + defer db.Close() + + var entries []*storage.TimeEntry + var projectName string + + if showAllProjects { + // Show project selection first + projects, err := db.GetProjectsWithCompletedEntries() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(projects) == 0 { + ui.PrintError(ui.EmojiError, "No completed time entries found") + ui.NewlineBelow() + os.Exit(1) + } + + projectPrompt := promptui.Select{ + Label: "Select project", + Items: projects, + } + + _, selectedProject, err := projectPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + projectName = selectedProject + } else { + // Use current project + detectedProject, err := project.DetectConfiguredProject() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + os.Exit(1) + } + projectName = detectedProject + } + + // Get completed entries for the selected/detected project + entries, err = db.GetCompletedEntriesByProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - if len(projects) == 0 { - ui.PrintError(ui.EmojiError, "No completed time entries found") + if len(entries) == 0 { + ui.PrintError(ui.EmojiError, fmt.Sprintf("No completed time entries found for project '%s'", projectName)) + if !showAllProjects { + ui.PrintMuted(0, "Use 'tmpo edit --show-all-projects' to see entries from all projects") + } ui.NewlineBelow() os.Exit(1) } - projectPrompt := promptui.Select{ - Label: "Select project", - Items: projects, + // Format entries for selection + templates := &promptui.SelectTemplates{ + Label: "{{ . }}", + Active: "▸ {{ .Label }}", + Inactive: " {{ .Label }}", + Selected: "{{ .Label }}", + } + + type entryItem struct { + Label string + Entry *storage.TimeEntry + } + + var items []entryItem + for _, entry := range entries { + label := formatEntryLabel(entry) + items = append(items, entryItem{Label: label, Entry: entry}) + } + + entryPrompt := promptui.Select{ + Label: "Select entry to edit", + Items: items, + Templates: templates, } - _, selectedProject, err := projectPrompt.Run() + idx, _, err := entryPrompt.Run() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - projectName = selectedProject - } else { - // Use current project - detectedProject, err := project.DetectConfiguredProject() + selectedEntry := items[idx].Entry + + // Create a copy of the entry for editing + editedEntry := &storage.TimeEntry{ + ID: selectedEntry.ID, + ProjectName: selectedEntry.ProjectName, + StartTime: selectedEntry.StartTime, + EndTime: selectedEntry.EndTime, + Description: selectedEntry.Description, + HourlyRate: selectedEntry.HourlyRate, + } + + // Edit start date + currentStartDate := selectedEntry.StartTime.Format("01-02-2006") + startDatePrompt := promptui.Prompt{ + Label: fmt.Sprintf("Start date (MM-DD-YYYY): (%s)", currentStartDate), + Validate: validateDateOptional, + AllowEdit: true, + } + + startDateInput, err := startDatePrompt.Run() if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - projectName = detectedProject - } - // Get completed entries for the selected/detected project - entries, err = db.GetCompletedEntriesByProject(projectName) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + startDateInput = strings.TrimSpace(startDateInput) + if startDateInput == "" { + startDateInput = currentStartDate + } - if len(entries) == 0 { - ui.PrintError(ui.EmojiError, fmt.Sprintf("No completed time entries found for project '%s'", projectName)) - if !showAllProjects { - ui.PrintMuted(0, "Use 'tmpo edit --show-all-projects' to see entries from all projects") + // Edit start time + currentStartTime := selectedEntry.StartTime.Format("3:04 PM") + startTimePrompt := promptui.Prompt{ + Label: fmt.Sprintf("Start time (e.g., 9:30 AM or 14:30): (%s)", currentStartTime), + Validate: validateTimeOptional, + AllowEdit: true, } - ui.NewlineBelow() - os.Exit(1) - } - - // Format entries for selection - templates := &promptui.SelectTemplates{ - Label: "{{ . }}", - Active: "▸ {{ .Label }}", - Inactive: " {{ .Label }}", - Selected: "{{ .Label }}", - } - - type entryItem struct { - Label string - Entry *storage.TimeEntry - } - - var items []entryItem - for _, entry := range entries { - label := formatEntryLabel(entry) - items = append(items, entryItem{Label: label, Entry: entry}) - } - - entryPrompt := promptui.Select{ - Label: "Select entry to edit", - Items: items, - Templates: templates, - } - - idx, _, err := entryPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - selectedEntry := items[idx].Entry - - // Create a copy of the entry for editing - editedEntry := &storage.TimeEntry{ - ID: selectedEntry.ID, - ProjectName: selectedEntry.ProjectName, - StartTime: selectedEntry.StartTime, - EndTime: selectedEntry.EndTime, - Description: selectedEntry.Description, - HourlyRate: selectedEntry.HourlyRate, - } - - // Edit start date - currentStartDate := selectedEntry.StartTime.Format("01-02-2006") - startDatePrompt := promptui.Prompt{ - Label: fmt.Sprintf("Start date (MM-DD-YYYY): (%s)", currentStartDate), - Validate: validateDateOptional, - AllowEdit: true, - } - - startDateInput, err := startDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - startDateInput = strings.TrimSpace(startDateInput) - if startDateInput == "" { - startDateInput = currentStartDate - } - - // Edit start time - currentStartTime := selectedEntry.StartTime.Format("3:04 PM") - startTimePrompt := promptui.Prompt{ - Label: fmt.Sprintf("Start time (e.g., 9:30 AM or 14:30): (%s)", currentStartTime), - Validate: validateTimeOptional, - AllowEdit: true, - } - - startTimeInput, err := startTimePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - startTimeInput = strings.TrimSpace(startTimeInput) - if startTimeInput == "" { - startTimeInput = currentStartTime - } - - // Edit end date - currentEndDate := selectedEntry.EndTime.Format("01-02-2006") - endDatePrompt := promptui.Prompt{ - Label: fmt.Sprintf("End date (MM-DD-YYYY): (%s)", currentEndDate), - Validate: validateDateOptional, - AllowEdit: true, - } - - endDateInput, err := endDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endDateInput = strings.TrimSpace(endDateInput) - if endDateInput == "" { - endDateInput = currentEndDate - } - - // Edit end time - currentEndTime := selectedEntry.EndTime.Format("3:04 PM") - endTimePrompt := promptui.Prompt{ - Label: fmt.Sprintf("End time (e.g., 5:00 PM or 17:00): (%s)", currentEndTime), - Validate: validateTimeOptional, - AllowEdit: true, - } - - endTimeInput, err := endTimePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endTimeInput = strings.TrimSpace(endTimeInput) - if endTimeInput == "" { - endTimeInput = currentEndTime - } - - // Validate that end is after start - if err := validateEndDateTime(startDateInput, startTimeInput, endDateInput, endTimeInput); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - // Edit description - currentDescription := selectedEntry.Description - descriptionLabel := "Description" - if currentDescription != "" { - descriptionLabel = fmt.Sprintf("Description: (%s)", currentDescription) - } - descriptionPrompt := promptui.Prompt{ - Label: descriptionLabel, - AllowEdit: true, - } - - descriptionInput, err := descriptionPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - descriptionInput = strings.TrimSpace(descriptionInput) - if descriptionInput == "" { - descriptionInput = currentDescription - } - - // Parse the new times - newStartTime, err := parseDateTime(startDateInput, startTimeInput) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) - os.Exit(1) - } - - newEndTime, err := parseDateTime(endDateInput, endTimeInput) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) - os.Exit(1) - } - - // Update the edited entry - editedEntry.StartTime = newStartTime - editedEntry.EndTime = &newEndTime - editedEntry.Description = descriptionInput - - // Show confirmation with diff - fmt.Println() - ui.PrintInfo(0, ui.Bold("Changes to entry"), fmt.Sprintf("#%d", selectedEntry.ID)) - fmt.Println() - - hasChanges := false - - if !selectedEntry.StartTime.Equal(editedEntry.StartTime) { - hasChanges = true - oldStr := selectedEntry.StartTime.Format("01-02-2006 3:04 PM") - newStr := editedEntry.StartTime.Format("01-02-2006 3:04 PM") - fmt.Printf(" %s %s → %s\n", ui.Bold("Start time:"), ui.Muted(oldStr), newStr) - } - - if !selectedEntry.EndTime.Equal(*editedEntry.EndTime) { - hasChanges = true - oldStr := selectedEntry.EndTime.Format("01-02-2006 3:04 PM") - newStr := editedEntry.EndTime.Format("01-02-2006 3:04 PM") - fmt.Printf(" %s %s → %s\n", ui.Bold("End time:"), ui.Muted(oldStr), newStr) - } - - if selectedEntry.Description != editedEntry.Description { - hasChanges = true - fmt.Printf(" %s %s → %s\n", ui.Bold("Description:"), ui.Muted(fmt.Sprintf("%q", selectedEntry.Description)), fmt.Sprintf("%q", editedEntry.Description)) - } - - if !hasChanges { - ui.PrintWarning(ui.EmojiWarning, "No changes detected") - ui.NewlineBelow() - os.Exit(0) - } - fmt.Println() + startTimeInput, err := startTimePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + startTimeInput = strings.TrimSpace(startTimeInput) + if startTimeInput == "" { + startTimeInput = currentStartTime + } + + // Edit end date + currentEndDate := selectedEntry.EndTime.Format("01-02-2006") + endDatePrompt := promptui.Prompt{ + Label: fmt.Sprintf("End date (MM-DD-YYYY): (%s)", currentEndDate), + Validate: validateDateOptional, + AllowEdit: true, + } - // Confirm save - confirmPrompt := promptui.Select{ - Label: "Save changes?", - Items: []string{"Yes", "No"}, - } + endDateInput, err := endDatePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endDateInput = strings.TrimSpace(endDateInput) + if endDateInput == "" { + endDateInput = currentEndDate + } - _, result, err := confirmPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + // Edit end time + currentEndTime := selectedEntry.EndTime.Format("3:04 PM") + endTimePrompt := promptui.Prompt{ + Label: fmt.Sprintf("End time (e.g., 5:00 PM or 17:00): (%s)", currentEndTime), + Validate: validateTimeOptional, + AllowEdit: true, + } + + endTimeInput, err := endTimePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endTimeInput = strings.TrimSpace(endTimeInput) + if endTimeInput == "" { + endTimeInput = currentEndTime + } + + // Validate that end is after start + if err := validateEndDateTime(startDateInput, startTimeInput, endDateInput, endTimeInput); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + // Edit description + currentDescription := selectedEntry.Description + descriptionLabel := "Description" + if currentDescription != "" { + descriptionLabel = fmt.Sprintf("Description: (%s)", currentDescription) + } + descriptionPrompt := promptui.Prompt{ + Label: descriptionLabel, + AllowEdit: true, + } + + descriptionInput, err := descriptionPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + descriptionInput = strings.TrimSpace(descriptionInput) + if descriptionInput == "" { + descriptionInput = currentDescription + } + + // Parse the new times + newStartTime, err := parseDateTime(startDateInput, startTimeInput) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) + os.Exit(1) + } + + newEndTime, err := parseDateTime(endDateInput, endTimeInput) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) + os.Exit(1) + } + + // Update the edited entry + editedEntry.StartTime = newStartTime + editedEntry.EndTime = &newEndTime + editedEntry.Description = descriptionInput + + // Show confirmation with diff + fmt.Println() + ui.PrintInfo(0, ui.Bold("Changes to entry"), fmt.Sprintf("#%d", selectedEntry.ID)) + fmt.Println() + + hasChanges := false + + if !selectedEntry.StartTime.Equal(editedEntry.StartTime) { + hasChanges = true + oldStr := selectedEntry.StartTime.Format("01-02-2006 3:04 PM") + newStr := editedEntry.StartTime.Format("01-02-2006 3:04 PM") + fmt.Printf(" %s %s → %s\n", ui.Bold("Start time:"), ui.Muted(oldStr), newStr) + } + + if !selectedEntry.EndTime.Equal(*editedEntry.EndTime) { + hasChanges = true + oldStr := selectedEntry.EndTime.Format("01-02-2006 3:04 PM") + newStr := editedEntry.EndTime.Format("01-02-2006 3:04 PM") + fmt.Printf(" %s %s → %s\n", ui.Bold("End time:"), ui.Muted(oldStr), newStr) + } + + if selectedEntry.Description != editedEntry.Description { + hasChanges = true + fmt.Printf(" %s %s → %s\n", ui.Bold("Description:"), ui.Muted(fmt.Sprintf("%q", selectedEntry.Description)), fmt.Sprintf("%q", editedEntry.Description)) + } + + if !hasChanges { + ui.PrintWarning(ui.EmojiWarning, "No changes detected") + ui.NewlineBelow() + os.Exit(0) + } + + fmt.Println() + + // Confirm save + confirmPrompt := promptui.Select{ + Label: "Save changes?", + Items: []string{"Yes", "No"}, + } + + _, result, err := confirmPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if result == "No" { + ui.PrintWarning(ui.EmojiWarning, "Changes discarded") + ui.NewlineBelow() + os.Exit(0) + } + + // Save to database + if err := db.UpdateTimeEntry(editedEntry.ID, editedEntry); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - if result == "No" { - ui.PrintWarning(ui.EmojiWarning, "Changes discarded") + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, "Entry updated successfully") ui.NewlineBelow() - os.Exit(0) - } - - // Save to database - if err := db.UpdateTimeEntry(editedEntry.ID, editedEntry); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - fmt.Println() - ui.PrintSuccess(ui.EmojiSuccess, "Entry updated successfully") - ui.NewlineBelow() - }, + }, } cmd.Flags().BoolVar(&showAllProjects, "show-all-projects", false, "Show project selection before entry selection") diff --git a/cmd/entries/manual.go b/cmd/entries/manual.go index 439f30a..406ce0c 100644 --- a/cmd/entries/manual.go +++ b/cmd/entries/manual.go @@ -14,165 +14,169 @@ import ( "github.com/spf13/cobra" ) -var manualCmd = &cobra.Command{ - Use: "manual", - Short: "Create a manual time entry", - Long: `Create a completed time entry by specifying start and end times using an interactive menu.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - ui.PrintSuccess(ui.EmojiManual, "Create Manual Time Entry") - fmt.Println() - - defaultProject := detectProjectNameWithSource() - - var projectLabel string - if defaultProject != "" { - projectLabel = fmt.Sprintf("Project name: (%s)", defaultProject) - } else { - projectLabel = "Project name" - } - - projectPrompt := promptui.Prompt{ - Label: projectLabel, - AllowEdit: true, - } - - projectInput, err := projectPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - projectName := strings.TrimSpace(projectInput) - if projectName == "" { - projectName = defaultProject - } - - if projectName == "" { - ui.PrintError(ui.EmojiError, "project name cannot be empty") - os.Exit(1) - } - - startDatePrompt := promptui.Prompt{ - Label: "Start date (MM-DD-YYYY)", - Validate: validateDate, - } - - startDateInput, err := startDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - startTimePrompt := promptui.Prompt{ - Label: "Start time (e.g., 9:30 AM or 14:30)", - Validate: validateTime, - } - - startTimeStr, err := startTimePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endDateLabel := fmt.Sprintf("End date (MM-DD-YYYY): (%s)", startDateInput) - - endDatePrompt := promptui.Prompt{ - Label: endDateLabel, - AllowEdit: true, - } - - endDateInput, err := endDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endDateInput = strings.TrimSpace(endDateInput) - if endDateInput == "" { - endDateInput = startDateInput - } - - if err := validateDate(endDateInput); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endTimePrompt := promptui.Prompt{ - Label: "End time (e.g., 5:00 PM or 17:00)", - Validate: validateTime, - } - - endTimeStr, err := endTimePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if err := validateEndDateTime(startDateInput, startTimeStr, endDateInput, endTimeStr); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - descriptionPrompt := promptui.Prompt{ - Label: "Description (optional, press Enter to skip)", - } - - description, err := descriptionPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - startTime, err := parseDateTime(startDateInput, startTimeStr) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) - os.Exit(1) - } - - endTime, err := parseDateTime(endDateInput, endTimeStr) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) - os.Exit(1) - } - - var hourlyRate *float64 - if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { - hourlyRate = &cfg.HourlyRate - } - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - defer db.Close() - - entry, err := db.CreateManualEntry(projectName, description, startTime, endTime, hourlyRate) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - duration := entry.Duration() - fmt.Println() - ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created manual entry for %s", ui.Bold(entry.ProjectName))) - ui.PrintInfo(4, ui.Bold("Start"), startTime.Format("Jan 2, 2006 at 3:04 PM")) - ui.PrintInfo(4, ui.Bold("End"), endTime.Format("Jan 2, 2006 at 3:04 PM")) - ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(duration)) - - if entry.Description != "" { - ui.PrintInfo(4, ui.Bold("Description"), entry.Description) - } - - if entry.HourlyRate != nil { - earnings := duration.Hours() * *entry.HourlyRate - fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), fmt.Sprintf("$%.2f", *entry.HourlyRate)) - fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), fmt.Sprintf("$%.2f", earnings)) - } - - ui.NewlineBelow() - }, +func ManualCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "manual", + Short: "Create a manual time entry", + Long: `Create a completed time entry by specifying start and end times using an interactive menu.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + ui.PrintSuccess(ui.EmojiManual, "Create Manual Time Entry") + fmt.Println() + + defaultProject := detectProjectNameWithSource() + + var projectLabel string + if defaultProject != "" { + projectLabel = fmt.Sprintf("Project name: (%s)", defaultProject) + } else { + projectLabel = "Project name" + } + + projectPrompt := promptui.Prompt{ + Label: projectLabel, + AllowEdit: true, + } + + projectInput, err := projectPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + projectName := strings.TrimSpace(projectInput) + if projectName == "" { + projectName = defaultProject + } + + if projectName == "" { + ui.PrintError(ui.EmojiError, "project name cannot be empty") + os.Exit(1) + } + + startDatePrompt := promptui.Prompt{ + Label: "Start date (MM-DD-YYYY)", + Validate: validateDate, + } + + startDateInput, err := startDatePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + startTimePrompt := promptui.Prompt{ + Label: "Start time (e.g., 9:30 AM or 14:30)", + Validate: validateTime, + } + + startTimeStr, err := startTimePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endDateLabel := fmt.Sprintf("End date (MM-DD-YYYY): (%s)", startDateInput) + + endDatePrompt := promptui.Prompt{ + Label: endDateLabel, + AllowEdit: true, + } + + endDateInput, err := endDatePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endDateInput = strings.TrimSpace(endDateInput) + if endDateInput == "" { + endDateInput = startDateInput + } + + if err := validateDate(endDateInput); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endTimePrompt := promptui.Prompt{ + Label: "End time (e.g., 5:00 PM or 17:00)", + Validate: validateTime, + } + + endTimeStr, err := endTimePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if err := validateEndDateTime(startDateInput, startTimeStr, endDateInput, endTimeStr); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + descriptionPrompt := promptui.Prompt{ + Label: "Description (optional, press Enter to skip)", + } + + description, err := descriptionPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + startTime, err := parseDateTime(startDateInput, startTimeStr) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) + os.Exit(1) + } + + endTime, err := parseDateTime(endDateInput, endTimeStr) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) + os.Exit(1) + } + + var hourlyRate *float64 + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { + hourlyRate = &cfg.HourlyRate + } + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + defer db.Close() + + entry, err := db.CreateManualEntry(projectName, description, startTime, endTime, hourlyRate) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + duration := entry.Duration() + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created manual entry for %s", ui.Bold(entry.ProjectName))) + ui.PrintInfo(4, ui.Bold("Start"), startTime.Format("Jan 2, 2006 at 3:04 PM")) + ui.PrintInfo(4, ui.Bold("End"), endTime.Format("Jan 2, 2006 at 3:04 PM")) + ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(duration)) + + if entry.Description != "" { + ui.PrintInfo(4, ui.Bold("Description"), entry.Description) + } + + if entry.HourlyRate != nil { + earnings := duration.Hours() * *entry.HourlyRate + fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), fmt.Sprintf("$%.2f", *entry.HourlyRate)) + fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), fmt.Sprintf("$%.2f", earnings)) + } + + ui.NewlineBelow() + }, + } + + return cmd } // validateDate validates that the provided input is a non-empty date string in MM-DD-YYYY format. @@ -285,7 +289,3 @@ func detectProjectNameWithSource() (string) { return projectName } - -func init() { - rootCmd.AddCommand(manualCmd) -} From 89cf827afc694bd2bee1b6abfec9dd935444523b Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 21 Dec 2025 23:24:35 -0700 Subject: [PATCH 3/8] Refactor delete command to use function-based command Refactored the delete command to use a function (DeleteCmd) that returns a *cobra.Command instead of a package-level variable. Removed the init() function and moved flag registration into DeleteCmd. This improves modularity and aligns with best practices for Cobra command structure. --- cmd/entries/delete.go | 256 +++++++++++++++++++++--------------------- 1 file changed, 128 insertions(+), 128 deletions(-) diff --git a/cmd/entries/delete.go b/cmd/entries/delete.go index c0b7b3e..7f23fe0 100644 --- a/cmd/entries/delete.go +++ b/cmd/entries/delete.go @@ -13,156 +13,162 @@ import ( var showAllProjectsDelete bool -var deleteCmd = &cobra.Command{ - Use: "delete", - Short: "Delete a time entry", - Long: `Delete a time entry using an interactive menu.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - ui.PrintSuccess("🗑️", "Delete Time Entry") - fmt.Println() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - defer db.Close() - - var entries []*storage.TimeEntry - var projectName string - - if showAllProjectsDelete { - // Show project selection first - projects, err := db.GetAllProjects() +func DeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a time entry", + Long: `Delete a time entry using an interactive menu.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + ui.PrintSuccess("🗑️", "Delete Time Entry") + fmt.Println() + + db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - - if len(projects) == 0 { - ui.PrintError(ui.EmojiError, "No time entries found") - ui.NewlineBelow() - os.Exit(1) - } - - projectPrompt := promptui.Select{ - Label: "Select project", - Items: projects, + defer db.Close() + + var entries []*storage.TimeEntry + var projectName string + + if showAllProjectsDelete { + // Show project selection first + projects, err := db.GetAllProjects() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(projects) == 0 { + ui.PrintError(ui.EmojiError, "No time entries found") + ui.NewlineBelow() + os.Exit(1) + } + + projectPrompt := promptui.Select{ + Label: "Select project", + Items: projects, + } + + _, selectedProject, err := projectPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + projectName = selectedProject + } else { + // Use current project + detectedProject, err := project.DetectConfiguredProject() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + os.Exit(1) + } + projectName = detectedProject } - _, selectedProject, err := projectPrompt.Run() + // Get all entries for the selected/detected project + entries, err = db.GetEntriesByProject(projectName) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - projectName = selectedProject - } else { - // Use current project - detectedProject, err := project.DetectConfiguredProject() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + if len(entries) == 0 { + ui.PrintError(ui.EmojiError, fmt.Sprintf("No time entries found for project '%s'", projectName)) + if !showAllProjectsDelete { + ui.PrintMuted(0, "Use 'tmpo delete --show-all-projects' to see entries from all projects") + } + ui.NewlineBelow() os.Exit(1) } - projectName = detectedProject - } - // Get all entries for the selected/detected project - entries, err = db.GetEntriesByProject(projectName) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + // Format entries for selection + templates := &promptui.SelectTemplates{ + Label: "{{ . }}", + Active: "▸ {{ .Label }}", + Inactive: " {{ .Label }}", + Selected: "{{ .Label }}", + } - if len(entries) == 0 { - ui.PrintError(ui.EmojiError, fmt.Sprintf("No time entries found for project '%s'", projectName)) - if !showAllProjectsDelete { - ui.PrintMuted(0, "Use 'tmpo delete --show-all-projects' to see entries from all projects") + type entryItem struct { + Label string + Entry *storage.TimeEntry } - ui.NewlineBelow() - os.Exit(1) - } - // Format entries for selection - templates := &promptui.SelectTemplates{ - Label: "{{ . }}", - Active: "▸ {{ .Label }}", - Inactive: " {{ .Label }}", - Selected: "{{ .Label }}", - } + var items []entryItem + for _, entry := range entries { + label := formatEntryLabelForDelete(entry) + items = append(items, entryItem{Label: label, Entry: entry}) + } - type entryItem struct { - Label string - Entry *storage.TimeEntry - } + entryPrompt := promptui.Select{ + Label: "Select entry to delete", + Items: items, + Templates: templates, + } - var items []entryItem - for _, entry := range entries { - label := formatEntryLabelForDelete(entry) - items = append(items, entryItem{Label: label, Entry: entry}) - } + idx, _, err := entryPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - entryPrompt := promptui.Select{ - Label: "Select entry to delete", - Items: items, - Templates: templates, - } + selectedEntry := items[idx].Entry + + // Show entry details and confirmation + fmt.Println() + ui.PrintWarning(ui.EmojiWarning, "You are about to delete this entry:") + fmt.Println() + ui.PrintInfo(4, ui.Bold("ID"), fmt.Sprintf("%d", selectedEntry.ID)) + ui.PrintInfo(4, ui.Bold("Project"), selectedEntry.ProjectName) + ui.PrintInfo(4, ui.Bold("Start"), selectedEntry.StartTime.Format("Jan 2, 2006 at 3:04 PM")) + if selectedEntry.EndTime != nil { + ui.PrintInfo(4, ui.Bold("End"), selectedEntry.EndTime.Format("Jan 2, 2006 at 3:04 PM")) + ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(selectedEntry.Duration())) + } else { + ui.PrintInfo(4, ui.Bold("Status"), ui.Warning("Running")) + } + if selectedEntry.Description != "" { + ui.PrintInfo(4, ui.Bold("Description"), selectedEntry.Description) + } + fmt.Println() - idx, _, err := entryPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + // Confirm deletion + confirmPrompt := promptui.Select{ + Label: "Are you sure you want to delete this entry?", + Items: []string{"No", "Yes"}, + } - selectedEntry := items[idx].Entry - - // Show entry details and confirmation - fmt.Println() - ui.PrintWarning(ui.EmojiWarning, "You are about to delete this entry:") - fmt.Println() - ui.PrintInfo(4, ui.Bold("ID"), fmt.Sprintf("%d", selectedEntry.ID)) - ui.PrintInfo(4, ui.Bold("Project"), selectedEntry.ProjectName) - ui.PrintInfo(4, ui.Bold("Start"), selectedEntry.StartTime.Format("Jan 2, 2006 at 3:04 PM")) - if selectedEntry.EndTime != nil { - ui.PrintInfo(4, ui.Bold("End"), selectedEntry.EndTime.Format("Jan 2, 2006 at 3:04 PM")) - ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(selectedEntry.Duration())) - } else { - ui.PrintInfo(4, ui.Bold("Status"), ui.Warning("Running")) - } - if selectedEntry.Description != "" { - ui.PrintInfo(4, ui.Bold("Description"), selectedEntry.Description) - } - fmt.Println() + _, result, err := confirmPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - // Confirm deletion - confirmPrompt := promptui.Select{ - Label: "Are you sure you want to delete this entry?", - Items: []string{"No", "Yes"}, - } + if result == "No" { + ui.PrintWarning(ui.EmojiWarning, "Deletion cancelled") + ui.NewlineBelow() + os.Exit(0) + } - _, result, err := confirmPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + // Delete from database + if err := db.DeleteTimeEntry(selectedEntry.ID); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - if result == "No" { - ui.PrintWarning(ui.EmojiWarning, "Deletion cancelled") + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, "Entry deleted successfully") ui.NewlineBelow() - os.Exit(0) - } + }, + } - // Delete from database - if err := db.DeleteTimeEntry(selectedEntry.ID); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + cmd.Flags().BoolVar(&showAllProjectsDelete, "show-all-projects", false, "Show project selection before entry selection") - fmt.Println() - ui.PrintSuccess(ui.EmojiSuccess, "Entry deleted successfully") - ui.NewlineBelow() - }, + return cmd } // formatEntryLabelForDelete formats a time entry for display in the delete selection list @@ -191,9 +197,3 @@ func formatEntryLabelForDelete(entry *storage.TimeEntry) string { return fmt.Sprintf("%s → %s (%s) - %s", startStr, endStr, durationStr, description) } - -func init() { - rootCmd.AddCommand(deleteCmd) - - deleteCmd.Flags().BoolVar(&showAllProjectsDelete, "show-all-projects", false, "Show project selection before entry selection") -} From be02de018d155c4c93488f2454c7c073eb6f269d Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 21 Dec 2025 23:30:00 -0700 Subject: [PATCH 4/8] Refactor tracking commands to use constructor functions Converted pause, resume, start, status, and stop commands from package-level variables to constructor functions returning *cobra.Command. This improves testability and modularity by avoiding global state and making command registration more explicit. --- cmd/tracking/pause.go | 82 +++++++++++++------------ cmd/tracking/resume.go | 94 ++++++++++++++-------------- cmd/tracking/start.go | 136 +++++++++++++++++++++-------------------- cmd/tracking/status.go | 84 +++++++++++++------------ cmd/tracking/stop.go | 83 +++++++++++++------------ 5 files changed, 249 insertions(+), 230 deletions(-) diff --git a/cmd/tracking/pause.go b/cmd/tracking/pause.go index 3d639b9..fd57c4a 100644 --- a/cmd/tracking/pause.go +++ b/cmd/tracking/pause.go @@ -10,45 +10,49 @@ import ( "github.com/spf13/cobra" ) -var pauseCmd = &cobra.Command{ - Use: "pause", - Short: "Pause time tracking", - Long: `Pause the currently running time tracking session. Use 'tmpo resume' to continue tracking.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if running == nil { - ui.PrintWarning(ui.EmojiWarning, "No active time tracking session to pause.") - ui.NewlineBelow() - os.Exit(0) - } - - err = db.StopEntry(running.ID) - if(err != nil) { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } +func PauseCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pause", + Short: "Pause time tracking", + Long: `Pause the currently running time tracking session. Use 'tmpo resume' to continue tracking.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running == nil { + ui.PrintWarning(ui.EmojiWarning, "No active time tracking session to pause.") + ui.NewlineBelow() + os.Exit(0) + } + + err = db.StopEntry(running.ID) + if(err != nil) { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + duration := time.Since(running.StartTime) + + ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Paused tracking %s", ui.Bold(running.ProjectName))) + ui.PrintInfo(4, ui.Bold("Session Duration"), ui.FormatDuration(duration)) + ui.PrintMuted(4, "Use 'tmpo resume' to continue tracking") - duration := time.Since(running.StartTime) - - ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Paused tracking %s", ui.Bold(running.ProjectName))) - ui.PrintInfo(4, ui.Bold("Session Duration"), ui.FormatDuration(duration)) - ui.PrintMuted(4, "Use 'tmpo resume' to continue tracking") + ui.NewlineBelow() + }, + } - ui.NewlineBelow() - }, + return cmd } diff --git a/cmd/tracking/resume.go b/cmd/tracking/resume.go index b81175a..5d9e954 100644 --- a/cmd/tracking/resume.go +++ b/cmd/tracking/resume.go @@ -9,59 +9,63 @@ import ( "github.com/spf13/cobra" ) -var resumeCmd = &cobra.Command{ - Use: "resume", - Short: "Resume time tracking", - Long: `Resume time tracking by starting a new session with the same project and description as the last paused session.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() +func ResumeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "resume", + Short: "Resume time tracking", + Long: `Resume time tracking by starting a new session with the same project and description as the last paused session.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - defer db.Close() + defer db.Close() - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - if running != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) - ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") - ui.NewlineBelow() - os.Exit(1) - } + if running != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) + ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") + ui.NewlineBelow() + os.Exit(1) + } - lastStopped, err := db.GetLastStoppedEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + lastStopped, err := db.GetLastStoppedEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - if lastStopped == nil { - ui.PrintError(ui.EmojiError, "No previous session found to resume.") - ui.PrintMuted(0, "Use 'tmpo start' to begin a new session.") - ui.NewlineBelow() - os.Exit(1) - } + if lastStopped == nil { + ui.PrintError(ui.EmojiError, "No previous session found to resume.") + ui.PrintMuted(0, "Use 'tmpo start' to begin a new session.") + ui.NewlineBelow() + os.Exit(1) + } - entry, err := db.CreateEntry(lastStopped.ProjectName, lastStopped.Description, lastStopped.HourlyRate) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + entry, err := db.CreateEntry(lastStopped.ProjectName, lastStopped.Description, lastStopped.HourlyRate) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Resumed tracking time for %s", ui.Bold(entry.ProjectName))) + ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Resumed tracking time for %s", ui.Bold(entry.ProjectName))) - if entry.Description != "" { - ui.PrintInfo(4, "Description", entry.Description) - } + if entry.Description != "" { + ui.PrintInfo(4, "Description", entry.Description) + } + + ui.NewlineBelow() + }, + } - ui.NewlineBelow() - }, + return cmd } diff --git a/cmd/tracking/start.go b/cmd/tracking/start.go index b397d4f..76c576e 100644 --- a/cmd/tracking/start.go +++ b/cmd/tracking/start.go @@ -11,71 +11,75 @@ import ( "github.com/spf13/cobra" ) -var startCmd = &cobra.Command{ - Use: "start [description]", - Short: "Start tracking time", - Long: `Start a new time tracking session for the current project.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if running != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) - ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") +func StartCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "start [description]", + Short: "Start tracking time", + Long: `Start a new time tracking session for the current project.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) + ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") + ui.NewlineBelow() + os.Exit(1) + } + + projectName, err := project.DetectConfiguredProject() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + os.Exit(1) + } + + description := "" + if len(args) > 0 { + description = args[0] + } + + // Load config to get hourly rate if available + var hourlyRate *float64 + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { + hourlyRate = &cfg.HourlyRate + } + + entry, err := db.CreateEntry(projectName, description, hourlyRate) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Started tracking time for %s", ui.Bold(entry.ProjectName))) + + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { + ui.PrintMuted(4, "└─ Config Source: .tmporc") + } else if project.IsInGitRepo() { + ui.PrintMuted(4, "└─ Config Source: git repository") + } else { + ui.PrintMuted(4, "└─ Config Source: directory name") + } + + if description != "" { + ui.PrintInfo(4, "Description", description) + } + ui.NewlineBelow() - os.Exit(1) - } - - projectName, err := project.DetectConfiguredProject() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) - } - - description := "" - if len(args) > 0 { - description = args[0] - } - - // Load config to get hourly rate if available - var hourlyRate *float64 - if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { - hourlyRate = &cfg.HourlyRate - } - - entry, err := db.CreateEntry(projectName, description, hourlyRate) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Started tracking time for %s", ui.Bold(entry.ProjectName))) - - if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { - ui.PrintMuted(4, "└─ Config Source: .tmporc") - } else if project.IsInGitRepo() { - ui.PrintMuted(4, "└─ Config Source: git repository") - } else { - ui.PrintMuted(4, "└─ Config Source: directory name") - } - - if description != "" { - ui.PrintInfo(4, "Description", description) - } - - ui.NewlineBelow() - }, + }, + } + + return cmd } diff --git a/cmd/tracking/status.go b/cmd/tracking/status.go index f980791..da3a784 100644 --- a/cmd/tracking/status.go +++ b/cmd/tracking/status.go @@ -10,46 +10,50 @@ import ( "github.com/spf13/cobra" ) -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show current tracking status", - Long: `Display information about the currently running time tracking session.`, - - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if running == nil { - ui.PrintWarning(ui.EmojiWarning, "Not currently tracking time") - ui.NewlineBelow() - ui.PrintMuted(0, "Use 'tmpo start' to begin tracking") - ui.NewlineBelow() - return - } - - duration := time.Since(running.StartTime) +func StatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show current tracking status", + Long: `Display information about the currently running time tracking session.`, + + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running == nil { + ui.PrintWarning(ui.EmojiWarning, "Not currently tracking time") + ui.NewlineBelow() + ui.PrintMuted(0, "Use 'tmpo start' to begin tracking") + ui.NewlineBelow() + return + } + + duration := time.Since(running.StartTime) + + ui.PrintSuccess(ui.EmojiStatus, fmt.Sprintf("Currently tracking: %s", ui.Bold(running.ProjectName))) + ui.PrintInfo(4, ui.Bold("Started"), running.StartTime.Format("3:04 PM")) + ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(duration)) + + if running.Description != "" { + ui.PrintInfo(4, ui.Bold("Description"), running.Description) + } - ui.PrintSuccess(ui.EmojiStatus, fmt.Sprintf("Currently tracking: %s", ui.Bold(running.ProjectName))) - ui.PrintInfo(4, ui.Bold("Started"), running.StartTime.Format("3:04 PM")) - ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(duration)) - - if running.Description != "" { - ui.PrintInfo(4, ui.Bold("Description"), running.Description) - } + ui.NewlineBelow() + }, + } - ui.NewlineBelow() - }, + return cmd } diff --git a/cmd/tracking/stop.go b/cmd/tracking/stop.go index 21dc8a4..b6afaf6 100644 --- a/cmd/tracking/stop.go +++ b/cmd/tracking/stop.go @@ -10,44 +10,47 @@ import ( "github.com/spf13/cobra" ) -// stopCmd represents the stop command -var stopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop tracking time", - Long: `Stop the currently running time tracking session.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if running == nil { - ui.PrintWarning(ui.EmojiWarning, "No active time tracking session.") - os.Exit(0) - } - - err = db.StopEntry(running.ID) - if(err != nil) { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - duration := time.Since(running.StartTime) - - ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Stopped tracking %s", ui.Bold(running.ProjectName))) - ui.PrintInfo(4, ui.Bold("Total Duration"), ui.FormatDuration(duration)) - - ui.NewlineBelow() - }, +func StopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop tracking time", + Long: `Stop the currently running time tracking session.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running == nil { + ui.PrintWarning(ui.EmojiWarning, "No active time tracking session.") + os.Exit(0) + } + + err = db.StopEntry(running.ID) + if(err != nil) { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + duration := time.Since(running.StartTime) + + ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Stopped tracking %s", ui.Bold(running.ProjectName))) + ui.PrintInfo(4, ui.Bold("Total Duration"), ui.FormatDuration(duration)) + + ui.NewlineBelow() + }, + } + + return cmd } From 8db0dcbdf22eed7524e584524da9b1e4be10cb9c Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 21 Dec 2025 23:37:05 -0700 Subject: [PATCH 5/8] Refactor commands to use constructor functions Replaces global command variables and init() registration with exported constructor functions (e.g., ExportCmd, LogCmd, StatsCmd, InitCmd, VersionCmd) for each command. This improves modularity and testability by allowing commands to be instantiated and registered explicitly. --- cmd/history/export.go | 174 +++++++++++++++++++++--------------------- cmd/history/log.go | 162 +++++++++++++++++++-------------------- cmd/history/stats.go | 96 +++++++++++------------ cmd/setup/init.go | 172 ++++++++++++++++++++--------------------- cmd/version.go | 23 +++--- 5 files changed, 313 insertions(+), 314 deletions(-) diff --git a/cmd/history/export.go b/cmd/history/export.go index 2a99285..9861807 100644 --- a/cmd/history/export.go +++ b/cmd/history/export.go @@ -20,99 +20,99 @@ var ( exportWeek bool ) -var exportCmd = &cobra.Command{ - Use: "export", - Short: "Export time entries", - Long: `Export time tracking data to different formats.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - var entries []*storage.TimeEntry - - if exportToday { - start := time.Now().Truncate(24 * time.Hour) - end := start.Add(24 * time.Hour) - entries, err = db.GetEntriesByDateRange(start, end) - } else if exportWeek { - now := time.Now() - weekday := int(now.Weekday()) - if weekday == 0 { - weekday = 7 // sunday +func ExportCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "export", + Short: "Export time entries", + Long: `Export time tracking data to different formats.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) } - start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) - end := start.AddDate(0, 0, 7) - entries, err = db.GetEntriesByDateRange(start, end) - } else if exportProject != "" { - entries, err = db.GetEntriesByProject(exportProject) - } else { - entries, err = db.GetEntries(0) // all - } - - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(entries) == 0 { - ui.PrintWarning(ui.EmojiWarning, "No entries to export.") - ui.NewlineBelow() - os.Exit(0) - } + defer db.Close() + + var entries []*storage.TimeEntry + + if exportToday { + start := time.Now().Truncate(24 * time.Hour) + end := start.Add(24 * time.Hour) + entries, err = db.GetEntriesByDateRange(start, end) + } else if exportWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // sunday + } + + start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end := start.AddDate(0, 0, 7) + entries, err = db.GetEntriesByDateRange(start, end) + } else if exportProject != "" { + entries, err = db.GetEntriesByProject(exportProject) + } else { + entries, err = db.GetEntries(0) // all + } - filename := exportOutput - if filename == "" { - timestamp := time.Now().Format("2006-01-02") - ext := "csv" + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - if exportFormat == "json" { - ext = "json" + if len(entries) == 0 { + ui.PrintWarning(ui.EmojiWarning, "No entries to export.") + ui.NewlineBelow() + os.Exit(0) } - filename = fmt.Sprintf("tmpo-export-%s.%s", timestamp, ext) - } - - if exportFormat == "csv" && filepath.Ext(filename) != ".csv" { - filename += ".csv" - } else if exportFormat == "json" && filepath.Ext(filename) != ".json" { - filename += ".json" - } - - switch exportFormat { - case "csv": - err = export.ToCSV(entries, filename) - case "json": - err = export.ToJson(entries, filename) - default: - ui.PrintError(ui.EmojiError, fmt.Sprintf("Unknown format '%s'. Use 'csv' or 'json'", exportFormat)) - os.Exit(1) - } - - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - ui.PrintSuccess(ui.EmojiExport, fmt.Sprintf("Exported %s to %s", ui.Bold(fmt.Sprintf("%d entries", len(entries))), ui.Bold(filename))) - - ui.NewlineBelow() - }, -} + filename := exportOutput + if filename == "" { + timestamp := time.Now().Format("2006-01-02") + ext := "csv" + + if exportFormat == "json" { + ext = "json" + } + + filename = fmt.Sprintf("tmpo-export-%s.%s", timestamp, ext) + } + + if exportFormat == "csv" && filepath.Ext(filename) != ".csv" { + filename += ".csv" + } else if exportFormat == "json" && filepath.Ext(filename) != ".json" { + filename += ".json" + } + + switch exportFormat { + case "csv": + err = export.ToCSV(entries, filename) + case "json": + err = export.ToJson(entries, filename) + default: + ui.PrintError(ui.EmojiError, fmt.Sprintf("Unknown format '%s'. Use 'csv' or 'json'", exportFormat)) + os.Exit(1) + } + + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + ui.PrintSuccess(ui.EmojiExport, fmt.Sprintf("Exported %s to %s", ui.Bold(fmt.Sprintf("%d entries", len(entries))), ui.Bold(filename))) + + ui.NewlineBelow() + }, + } -func init() { - rootCmd.AddCommand(exportCmd) + cmd.Flags().StringVarP(&exportFormat, "format", "f", "csv", "Export format (csv or json)") + cmd.Flags().StringVarP(&exportOutput, "output", "o", "", "Output filename") + cmd.Flags().StringVarP(&exportProject, "project", "p", "", "Filter by project") + cmd.Flags().BoolVarP(&exportToday, "today", "t", false, "Export today's entries") + cmd.Flags().BoolVarP(&exportWeek, "week", "w", false, "Export this week's entries") - exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "csv", "Export format (csv or json)") - exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "Output filename") - exportCmd.Flags().StringVarP(&exportProject, "project", "p", "", "Filter by project") - exportCmd.Flags().BoolVarP(&exportToday, "today", "t", false, "Export today's entries") - exportCmd.Flags().BoolVarP(&exportWeek, "week", "w", false, "Export this week's entries") + return cmd } diff --git a/cmd/history/log.go b/cmd/history/log.go index d24f605..2e30a64 100644 --- a/cmd/history/log.go +++ b/cmd/history/log.go @@ -17,101 +17,101 @@ var ( logWeek bool ) -var logCmd = &cobra.Command{ - Use: "log", - Short: "View time tracking history", - Long: `Display past time tracking entries with optional filtering.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - var entries []*storage.TimeEntry - - if logToday { - start := time.Now().Truncate(24 * time.Hour) - end := start.Add(24 * time.Hour) - entries, err = db.GetEntriesByDateRange(start, end) - } else if logWeek { - now := time.Now() - weekday := int(now.Weekday()) - if weekday == 0 { - weekday = 7 // sunday +func LogCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "log", + Short: "View time tracking history", + Long: `Display past time tracking entries with optional filtering.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) } - start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) - end := start.AddDate(0, 0, 7) - entries, err = db.GetEntriesByDateRange(start, end) - } else if logProject != "" { - entries, err = db.GetEntriesByProject(logProject) - } else { - entries, err = db.GetEntries(logLimit) - } - - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(entries) == 0 { - ui.PrintWarning(ui.EmojiWarning, "No time entries found.") - ui.NewlineBelow() - return - } - - ui.PrintSuccess(ui.EmojiLog, fmt.Sprintf("Time Entries (%d total)", len(entries))) - fmt.Println() + defer db.Close() - var totalDuration time.Duration - currentDate := "" + var entries []*storage.TimeEntry - for _, entry := range entries { - entryDate := entry.StartTime.Format("Mon, Jan 2, 2006") - if entryDate != currentDate { - if currentDate != "" { - fmt.Println() + if logToday { + start := time.Now().Truncate(24 * time.Hour) + end := start.Add(24 * time.Hour) + entries, err = db.GetEntriesByDateRange(start, end) + } else if logWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // sunday } - fmt.Println(ui.Bold(ui.Muted(fmt.Sprintf("─── %s ───", entryDate)))) - currentDate = entryDate + start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end := start.AddDate(0, 0, 7) + entries, err = db.GetEntriesByDateRange(start, end) + } else if logProject != "" { + entries, err = db.GetEntriesByProject(logProject) + } else { + entries, err = db.GetEntries(logLimit) } - duration := entry.Duration() - totalDuration += duration + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - timeRange := entry.StartTime.Format("03:04 PM") + " - " - if entry.EndTime != nil { - timeRange += entry.EndTime.Format("03:04 PM") + " " - } else { - timeRange += ui.Warning("(running)") + " " + if len(entries) == 0 { + ui.PrintWarning(ui.EmojiWarning, "No time entries found.") + ui.NewlineBelow() + return } - fmt.Printf(" %s %s %s\n", timeRange, ui.Bold(fmt.Sprintf("%-20s", entry.ProjectName)), ui.FormatDuration(duration)) - if entry.Description != "" { - fmt.Printf(" %s %s\n", ui.Muted("└─"), entry.Description) + ui.PrintSuccess(ui.EmojiLog, fmt.Sprintf("Time Entries (%d total)", len(entries))) + fmt.Println() + + var totalDuration time.Duration + currentDate := "" + + for _, entry := range entries { + entryDate := entry.StartTime.Format("Mon, Jan 2, 2006") + if entryDate != currentDate { + if currentDate != "" { + fmt.Println() + } + + fmt.Println(ui.Bold(ui.Muted(fmt.Sprintf("─── %s ───", entryDate)))) + currentDate = entryDate + } + + duration := entry.Duration() + totalDuration += duration + + timeRange := entry.StartTime.Format("03:04 PM") + " - " + if entry.EndTime != nil { + timeRange += entry.EndTime.Format("03:04 PM") + " " + } else { + timeRange += ui.Warning("(running)") + " " + } + + fmt.Printf(" %s %s %s\n", timeRange, ui.Bold(fmt.Sprintf("%-20s", entry.ProjectName)), ui.FormatDuration(duration)) + if entry.Description != "" { + fmt.Printf(" %s %s\n", ui.Muted("└─"), entry.Description) + } } - } - fmt.Println() - ui.PrintSeparator() - fmt.Printf("%s %s\n", ui.BoldInfo("Total Time:"), ui.Bold(ui.FormatDuration(totalDuration))) + fmt.Println() + ui.PrintSeparator() + fmt.Printf("%s %s\n", ui.BoldInfo("Total Time:"), ui.Bold(ui.FormatDuration(totalDuration))) - ui.NewlineBelow() - }, -} + ui.NewlineBelow() + }, + } -func init() { - rootCmd.AddCommand(logCmd) + cmd.Flags().IntVarP(&logLimit, "limit", "l", 10, "Number of entries to show") + cmd.Flags().StringVarP(&logProject, "project", "p", "", "Filter by project name") + cmd.Flags().BoolVarP(&logToday, "today", "t", false, "Show today's entries") + cmd.Flags().BoolVarP(&logWeek, "week", "w", false, "Show this week's entries") - logCmd.Flags().IntVarP(&logLimit, "limit", "l", 10, "Number of entries to show") - logCmd.Flags().StringVarP(&logProject, "project", "p", "", "Filter by project name") - logCmd.Flags().BoolVarP(&logToday, "today", "t", false, "Show today's entries") - logCmd.Flags().BoolVarP(&logWeek, "week", "w", false, "Show this week's entries") + return cmd } diff --git a/cmd/history/stats.go b/cmd/history/stats.go index 8c57ba8..e96d579 100644 --- a/cmd/history/stats.go +++ b/cmd/history/stats.go @@ -16,57 +16,64 @@ var ( statsWeek bool ) -var statsCmd = &cobra.Command{ - Use: "stats", - Short: "Show time tracking statistics", - Long: `Display statistics and summaries of your time tracking data.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - var start, end time.Time - var periodName string +func StatsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stats", + Short: "Show time tracking statistics", + Long: `Display statistics and summaries of your time tracking data.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - if statsToday { - start = time.Now().Truncate(24 * time.Hour) - end = start.Add(24 * time.Hour) - periodName = "Today" - } else if statsWeek { - now := time.Now() - weekday := int(now.Weekday()) - if weekday == 0 { - weekday = 7 + defer db.Close() + + var start, end time.Time + var periodName string + + if statsToday { + start = time.Now().Truncate(24 * time.Hour) + end = start.Add(24 * time.Hour) + periodName = "Today" + } else if statsWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + + start = now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end = start.AddDate(0, 0, 7) + periodName = "This Week" + } else { + entries, err := db.GetEntries(0) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + ShowAllTimeStats(entries, db) + return } - start = now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) - end = start.AddDate(0, 0, 7) - periodName = "This Week" - } else { - entries, err := db.GetEntries(0) + entries, err := db.GetEntriesByDateRange(start, end) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - ShowAllTimeStats(entries, db) - return - } + ShowPeriodStats(entries, periodName) + }, + } - entries, err := db.GetEntriesByDateRange(start, end) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + cmd.Flags().BoolVarP(&statsToday, "today", "t", false, "Show today's stats") + cmd.Flags().BoolVarP(&statsWeek, "week", "w", false, "Show this week's stats") - ShowPeriodStats(entries, periodName) - }, + return cmd } // ShowPeriodStats prints aggregated statistics for a named period to standard @@ -217,10 +224,3 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { ui.NewlineBelow() } - -func init() { - rootCmd.AddCommand(statsCmd) - - statsCmd.Flags().BoolVarP(&statsToday, "today", "t", false, "Show today's stats") - statsCmd.Flags().BoolVarP(&statsWeek, "week", "w", false, "Show this week's stats") -} diff --git a/cmd/setup/init.go b/cmd/setup/init.go index ddc9a90..048a7a3 100644 --- a/cmd/setup/init.go +++ b/cmd/setup/init.go @@ -18,108 +18,114 @@ var ( acceptDefaults bool ) -var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize a .tmporc config file", - Long: `Create a .tmporc configuration file in the current directory using an interactive form.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - if _, err := os.Stat(".tmporc"); err == nil { - ui.PrintError(ui.EmojiError, ".tmporc already exists in this directory") - os.Exit(1) - } - - // Detect default project name - defaultName := detectDefaultProjectName() - - var name string - var hourlyRate float64 - var description string - - if acceptDefaults { - // Use all defaults without prompting - name = defaultName - hourlyRate = 0 - description = "" - } else { - // Interactive form - ui.PrintSuccess(ui.EmojiInit, "Initialize Project Configuration") - fmt.Println() - - // Project Name prompt - namePrompt := promptui.Prompt{ - Label: fmt.Sprintf("Project name (%s)", defaultName), - AllowEdit: true, - } - - nameInput, err := namePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) +func InitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize a .tmporc config file", + Long: `Create a .tmporc configuration file in the current directory using an interactive form.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + if _, err := os.Stat(".tmporc"); err == nil { + ui.PrintError(ui.EmojiError, ".tmporc already exists in this directory") os.Exit(1) } - name = strings.TrimSpace(nameInput) - if name == "" { + // Detect default project name + defaultName := detectDefaultProjectName() + + var name string + var hourlyRate float64 + var description string + + if acceptDefaults { + // Use all defaults without prompting name = defaultName - } + hourlyRate = 0 + description = "" + } else { + // Interactive form + ui.PrintSuccess(ui.EmojiInit, "Initialize Project Configuration") + fmt.Println() + + // Project Name prompt + namePrompt := promptui.Prompt{ + Label: fmt.Sprintf("Project name (%s)", defaultName), + AllowEdit: true, + } - // Hourly Rate prompt - ratePrompt := promptui.Prompt{ - Label: "Hourly rate (press Enter to skip)", - Validate: validateHourlyRate, - } + nameInput, err := namePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - rateInput, err := ratePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + name = strings.TrimSpace(nameInput) + if name == "" { + name = defaultName + } + + // Hourly Rate prompt + ratePrompt := promptui.Prompt{ + Label: "Hourly rate (press Enter to skip)", + Validate: validateHourlyRate, + } - rateInput = strings.TrimSpace(rateInput) - if rateInput != "" { - hourlyRate, err = strconv.ParseFloat(rateInput, 64) + rateInput, err := ratePrompt.Run() if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing hourly rate: %v", err)) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - } - // Description prompt - descPrompt := promptui.Prompt{ - Label: "Description (press Enter to skip)", + rateInput = strings.TrimSpace(rateInput) + if rateInput != "" { + hourlyRate, err = strconv.ParseFloat(rateInput, 64) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing hourly rate: %v", err)) + os.Exit(1) + } + } + + // Description prompt + descPrompt := promptui.Prompt{ + Label: "Description (press Enter to skip)", + } + + descInput, err := descPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + description = strings.TrimSpace(descInput) } - descInput, err := descPrompt.Run() + // Create the .tmporc file + err := config.CreateWithTemplate(name, hourlyRate, description) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - description = strings.TrimSpace(descInput) - } + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project %s", ui.Bold(name))) + if hourlyRate > 0 { + ui.PrintInfo(4, ui.Bold("Hourly Rate"), fmt.Sprintf("$%.2f", hourlyRate)) + } + if description != "" { + ui.PrintInfo(4, ui.Bold("Description"), description) + } - // Create the .tmporc file - err := config.CreateWithTemplate(name, hourlyRate, description) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + fmt.Println() + ui.PrintMuted(0, "You can edit .tmporc to customize your project settings.") - fmt.Println() - ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project %s", ui.Bold(name))) - if hourlyRate > 0 { - ui.PrintInfo(4, ui.Bold("Hourly Rate"), fmt.Sprintf("$%.2f", hourlyRate)) - } - if description != "" { - ui.PrintInfo(4, ui.Bold("Description"), description) - } + ui.NewlineBelow() + }, + } - fmt.Println() - ui.PrintMuted(0, "You can edit .tmporc to customize your project settings.") + cmd.Flags().BoolVarP(&acceptDefaults, "accept-defaults", "a", false, "Accept all defaults and skip interactive prompts") - ui.NewlineBelow() - }, + return cmd } // detectDefaultProjectName returns the auto-detected project name @@ -162,9 +168,3 @@ func validateHourlyRate(input string) error { return nil } - -func init() { - rootCmd.AddCommand(initCmd) - - initCmd.Flags().BoolVarP(&acceptDefaults, "accept-defaults", "a", false, "Accept all defaults and skip interactive prompts") -} diff --git a/cmd/version.go b/cmd/version.go index 0fd710d..5f3a370 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -11,14 +11,17 @@ import ( "github.com/spf13/cobra" ) -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Show version information", - Long: "Display the current version information including date and release URL.", - Hidden: true, - Run: func(cmd *cobra.Command, args []string) { - DisplayVersionWithUpdateCheck() - }, +func VersionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Show version information", + Long: "Display the current version information including date and release URL.", + Run: func(cmd *cobra.Command, args []string) { + DisplayVersionWithUpdateCheck() + }, + } + + return cmd } // DisplayVersionWithUpdateCheck displays the version information and checks for updates. @@ -78,7 +81,3 @@ func checkForUpdates() { fmt.Printf("%s\n\n", ui.Muted(updateInfo.UpdateURL)) } } - -func init() { - rootCmd.AddCommand(versionCmd) -} From b9af2fa6e4072e25671181f6d463fd3813ec56ae Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 21 Dec 2025 23:44:06 -0700 Subject: [PATCH 6/8] Refactor root command to modular subcommands Replaces the global rootCmd variable with a RootCmd() function that constructs the root command and adds subcommands for tracking, history, entries, and setup. This modularizes command registration and improves maintainability. --- cmd/root.go | 68 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 70dce17..3d63dc5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,10 @@ package cmd import ( "os" + "github.com/DylanDevelops/tmpo/cmd/entries" + "github.com/DylanDevelops/tmpo/cmd/history" + "github.com/DylanDevelops/tmpo/cmd/setup" + "github.com/DylanDevelops/tmpo/cmd/tracking" "github.com/spf13/cobra" ) @@ -12,34 +16,56 @@ var ( Date = "unknown" ) -var rootCmd = &cobra.Command{ - Use: "tmpo", - Short: "Minimal CLI time tracker for developers", - Long: `tmpo - Set the tmpo +func RootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tmpo", + Short: "Minimal CLI time tracker for developers", + Long: `tmpo - Set the tmpo A minimal, developer-friendly time tracking tool that lives in your terminal. Track time effortlessly with automatic project detection and simple commands.`, - Run: func(cmd *cobra.Command, args []string) { - // Check if version flag was set - versionFlag, _ := cmd.Flags().GetBool("version") - - if versionFlag { - DisplayVersionWithUpdateCheck() - return - } - - // Otherwise show help - cmd.Help() - }, + Run: func(cmd *cobra.Command, args []string) { + // Check if version flag was set + versionFlag, _ := cmd.Flags().GetBool("version") + + if versionFlag { + DisplayVersionWithUpdateCheck() + return + } + + // Otherwise show help + cmd.Help() + }, + } + + cmd.Flags().BoolP("version", "v", false, "version for tmpo") + + // Tracking + cmd.AddCommand(tracking.StartCmd()) + cmd.AddCommand(tracking.StopCmd()) + cmd.AddCommand(tracking.PauseCmd()) + cmd.AddCommand(tracking.ResumeCmd()) + cmd.AddCommand(tracking.StatusCmd()) + + // History + cmd.AddCommand(history.LogCmd()) + cmd.AddCommand(history.StatsCmd()) + cmd.AddCommand(history.ExportCmd()) + + // Entries + cmd.AddCommand(entries.EditCmd()) + cmd.AddCommand(entries.DeleteCmd()) + cmd.AddCommand(entries.ManualCmd()) + + // Setup + cmd.AddCommand(setup.InitCmd()) + + return cmd } func Execute() { - err := rootCmd.Execute() + err := RootCmd().Execute() if err != nil { os.Exit(1) } } - -func init() { - rootCmd.Flags().BoolP("version", "v", false, "version for tmpo") -} From 4f8a88089a323f980ae172141aa4a8c75a032737 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 21 Dec 2025 23:57:43 -0700 Subject: [PATCH 7/8] Refactor version command to utilities package Moved version-related code and tests from cmd to cmd/utilities for better organization. Updated .goreleaser.yml and root command to reference the new location. --- .goreleaser.yml | 18 +++++++++--------- cmd/root.go | 12 +++++------- cmd/{ => utilities}/version.go | 9 ++++++++- cmd/{ => utilities}/version_test.go | 2 +- 4 files changed, 23 insertions(+), 18 deletions(-) rename cmd/{ => utilities}/version.go (96%) rename cmd/{ => utilities}/version_test.go (99%) diff --git a/.goreleaser.yml b/.goreleaser.yml index 166983f..5f63208 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -12,9 +12,9 @@ builds: binary: tmpo ldflags: - -s -w - - -X github.com/DylanDevelops/tmpo/cmd.Version={{.Version}} - - -X github.com/DylanDevelops/tmpo/cmd.Commit={{.Commit}} - - -X github.com/DylanDevelops/tmpo/cmd.Date={{.Date}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Version={{.Version}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Commit={{.Commit}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Date={{.Date}} # Linux - id: linux @@ -25,9 +25,9 @@ builds: binary: tmpo ldflags: - -s -w - - -X github.com/DylanDevelops/tmpo/cmd.Version={{.Version}} - - -X github.com/DylanDevelops/tmpo/cmd.Commit={{.Commit}} - - -X github.com/DylanDevelops/tmpo/cmd.Date={{.Date}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Version={{.Version}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Commit={{.Commit}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Date={{.Date}} # Windows - id: windows @@ -38,9 +38,9 @@ builds: binary: tmpo ldflags: - -s -w - - -X github.com/DylanDevelops/tmpo/cmd.Version={{.Version}} - - -X github.com/DylanDevelops/tmpo/cmd.Commit={{.Commit}} - - -X github.com/DylanDevelops/tmpo/cmd.Date={{.Date}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Version={{.Version}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Commit={{.Commit}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Date={{.Date}} archives: - id: windows-zip diff --git a/cmd/root.go b/cmd/root.go index 3d63dc5..19ebb7b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,15 +7,10 @@ import ( "github.com/DylanDevelops/tmpo/cmd/history" "github.com/DylanDevelops/tmpo/cmd/setup" "github.com/DylanDevelops/tmpo/cmd/tracking" + "github.com/DylanDevelops/tmpo/cmd/utilities" "github.com/spf13/cobra" ) -var ( - Version = "dev" - Commit = "none" - Date = "unknown" -) - func RootCmd() *cobra.Command { cmd := &cobra.Command{ Use: "tmpo", @@ -29,7 +24,7 @@ Track time effortlessly with automatic project detection and simple commands.`, versionFlag, _ := cmd.Flags().GetBool("version") if versionFlag { - DisplayVersionWithUpdateCheck() + utilities.DisplayVersionWithUpdateCheck() return } @@ -40,6 +35,9 @@ Track time effortlessly with automatic project detection and simple commands.`, cmd.Flags().BoolP("version", "v", false, "version for tmpo") + // Utilities + cmd.AddCommand(utilities.VersionCmd()) + // Tracking cmd.AddCommand(tracking.StartCmd()) cmd.AddCommand(tracking.StopCmd()) diff --git a/cmd/version.go b/cmd/utilities/version.go similarity index 96% rename from cmd/version.go rename to cmd/utilities/version.go index 5f3a370..459f990 100644 --- a/cmd/version.go +++ b/cmd/utilities/version.go @@ -1,4 +1,4 @@ -package cmd +package utilities import ( "fmt" @@ -11,11 +11,18 @@ import ( "github.com/spf13/cobra" ) +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + func VersionCmd() *cobra.Command { cmd := &cobra.Command{ Use: "version", Short: "Show version information", Long: "Display the current version information including date and release URL.", + Hidden: true, Run: func(cmd *cobra.Command, args []string) { DisplayVersionWithUpdateCheck() }, diff --git a/cmd/version_test.go b/cmd/utilities/version_test.go similarity index 99% rename from cmd/version_test.go rename to cmd/utilities/version_test.go index 29942bf..b340e32 100644 --- a/cmd/version_test.go +++ b/cmd/utilities/version_test.go @@ -1,4 +1,4 @@ -package cmd +package utilities import ( "testing" From 781555880094c74dee996a09ca78698c00290584 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Mon, 22 Dec 2025 00:02:06 -0700 Subject: [PATCH 8/8] Update documentation for new command structure Revised CLAUDE.md and CONTRIBUTING.md to reflect the new organization of CLI commands into subdirectories and the use of constructor functions for command registration. Updated project structure diagrams and descriptions to match the current codebase layout, including new directories and changes to project detection logic. --- CLAUDE.md | 15 ++++++++------- CONTRIBUTING.md | 23 ++++++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a2d9bf2..37c067c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,8 +40,9 @@ goreleaser build --snapshot --clean **CLI Layer** (`cmd/`): - Uses Cobra for command structure -- Each command is a separate file (start.go, stop.go, status.go, etc.) -- All commands registered in `cmd/root.go` via `init()` functions +- Commands organized in subdirectories: tracking/, entries/, history/, setup/, utilities/ +- Each command is a constructor function that returns `*cobra.Command` +- All commands explicitly registered in `cmd/root.go` RootCmd() function - Version information is injected via ldflags during build **Storage Layer** (`internal/storage/`): @@ -83,7 +84,7 @@ defer db.Close() ``` **Project Name Detection:** -The `cmd/start.go:DetectProjectName()` function implements the priority: .tmporc config → Git repository → directory name +The `internal/project.DetectConfiguredProject()` function implements the priority: .tmporc config → Git repository → directory name **Time Entry States:** - Running: EndTime is nil @@ -107,13 +108,13 @@ Uses `modernc.org/sqlite` (pure Go, no CGO) instead of mattn/go-sqlite3. This is **Version Injection:** Version, Commit, and Date are injected at build time via ldflags: ``` --X github.com/DylanDevelops/tmpo/cmd.Version={{.Version}} --X github.com/DylanDevelops/tmpo/cmd.Commit={{.Commit}} --X github.com/DylanDevelops/tmpo/cmd.Date={{.Date}} +-X github.com/DylanDevelops/tmpo/cmd/utilities.Version={{.Version}} +-X github.com/DylanDevelops/tmpo/cmd/utilities.Commit={{.Commit}} +-X github.com/DylanDevelops/tmpo/cmd/utilities.Date={{.Date}} ``` **Command Registration:** -New commands must be added via `rootCmd.AddCommand()` in their `init()` function. +New commands should be created as constructor functions (e.g., `StartCmd()`, `StopCmd()`) that return `*cobra.Command`. Each command registers its own flags before returning. Commands are then registered in `cmd/root.go` RootCmd() by calling `cmd.AddCommand(tracking.StartCmd())`. **Interactive Prompts:** The `manual` command uses `github.com/manifoldco/promptui` for interactive prompts (date/time input). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c397c28..1634401 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,15 +59,18 @@ goreleaser build --snapshot --clean ``` tmpo/ ├── cmd/ # CLI commands (Using Cobra) -│ ├── command1.go -│ ├── command2.go -│ ├── command3.go -│ ├── ... +│ ├── root.go # Root command with RootCmd() constructor +│ ├── tracking/ # Time tracking commands (start, stop, pause, resume, status) +│ ├── entries/ # Entry management (edit, delete, manual) +│ ├── history/ # History commands (log, stats, export) +│ ├── setup/ # Setup commands (init) +│ └── utilities/ # Utility commands (version) ├── internal/ │ ├── config/ # Configuration management (.tmporc files) │ ├── storage/ # SQLite database layer │ ├── project/ # Project detection logic -│ └── export/ # Export functionality +│ ├── export/ # Export functionality +│ └── ui/ # UI helpers (formatting, colors, printing) ├── docs/ # User documentation │ ├── usage.md │ └── configuration.md @@ -78,10 +81,16 @@ tmpo/ ### Key Directories - **`cmd/`**: Contains all CLI command implementations using Cobra + - **`cmd/tracking/`**: Time tracking commands (start, stop, pause, resume, status) + - **`cmd/entries/`**: Entry management commands (edit, delete, manual) + - **`cmd/history/`**: History and reporting commands (log, stats, export) + - **`cmd/setup/`**: Setup and initialization commands (init) + - **`cmd/utilities/`**: Utility commands and version information (version) - **`internal/config/`**: Handles `.tmporc` file parsing and configuration - **`internal/storage/`**: SQLite database operations and models - **`internal/project/`**: Project name detection logic (git/directory/config) -- **`internal/export/`**: Export functionality used by commands +- **`internal/export/`**: Export functionality (CSV, JSON) +- **`internal/ui/`**: UI helpers for formatting, colors, and terminal output - **`docs/`**: User-facing documentation and guides ### Data Storage @@ -107,7 +116,7 @@ When a user runs `tmpo start`, the project name is detected in this priority ord 2. **Git repository** - Uses `git rev-parse --show-toplevel` to find repo root 3. **Directory name** - Falls back to current directory name -This logic lives in `internal/project/detector.go`. +This logic lives in `internal/project/detect.go`. ## Making Changes