From 273f442d96464169c92ea76ae86938cc63146ac9 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Tue, 16 Dec 2025 14:53:03 -0700 Subject: [PATCH 1/3] Refactor CLI output to use centralized UI formatting Replaces direct fmt print statements in command files with new functions from internal/ui/format.go for consistent colored and emoji-enhanced output. Adds internal/ui/format.go, which provides color constants, emoji constants, and utility functions for printing success, error, warning, info, muted messages, separators, and formatted durations. This improves the user experience with clearer, visually distinct CLI feedback and centralizes output formatting logic. --- cmd/export.go | 23 ++++---- cmd/init.go | 30 ++++++---- cmd/log.go | 35 +++++++----- cmd/manual.go | 46 ++++++++------- cmd/start.go | 33 +++++------ cmd/stats.go | 56 +++++++++--------- cmd/status.go | 28 +++++---- cmd/stop.go | 42 ++++---------- cmd/version.go | 7 ++- internal/ui/format.go | 130 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 287 insertions(+), 143 deletions(-) create mode 100644 internal/ui/format.go diff --git a/cmd/export.go b/cmd/export.go index 051e054..0500898 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -8,6 +8,7 @@ import ( "github.com/DylanDevelops/tmpo/internal/export" "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" "github.com/spf13/cobra" ) @@ -24,10 +25,11 @@ var exportCmd = &cobra.Command{ 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 { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -56,14 +58,13 @@ var exportCmd = &cobra.Command{ } if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } if len(entries) == 0 { - fmt.Println("No entries to export.") - + ui.PrintWarning(ui.EmojiWarning, "No entries to export.") + ui.NewlineBelow() os.Exit(0) } @@ -91,18 +92,18 @@ var exportCmd = &cobra.Command{ case "json": err = export.ToJson(entries, filename) default: - fmt.Fprintf(os.Stderr, "Error: Unknown format '%s'. Use 'csv' or 'json'\n", exportFormat) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("Unknown format '%s'. Use 'csv' or 'json'", exportFormat)) os.Exit(1) } if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - fmt.Printf("[tmpo] Exported %d entries to %s\n", len(entries), filename) + ui.PrintSuccess(ui.EmojiExport, fmt.Sprintf("Exported %d entries to %s", len(entries), filename)) + + ui.NewlineBelow() }, } diff --git a/cmd/init.go b/cmd/init.go index 24ebd11..f1901b3 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -9,6 +9,7 @@ import ( "github.com/DylanDevelops/tmpo/internal/config" "github.com/DylanDevelops/tmpo/internal/project" + "github.com/DylanDevelops/tmpo/internal/ui" "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -22,8 +23,10 @@ var initCmd = &cobra.Command{ 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 { - fmt.Println("Error: .tmporc already exists in this directory") + ui.PrintError(ui.EmojiError, ".tmporc already exists in this directory") os.Exit(1) } @@ -41,7 +44,8 @@ var initCmd = &cobra.Command{ description = "" } else { // Interactive form - fmt.Println("\n[tmpo] Initialize Project Configuration") + ui.PrintSuccess(ui.EmojiInit, "Initialize Project Configuration") + fmt.Println() // Project Name prompt namePrompt := promptui.Prompt{ @@ -51,7 +55,7 @@ var initCmd = &cobra.Command{ nameInput, err := namePrompt.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -68,7 +72,7 @@ var initCmd = &cobra.Command{ rateInput, err := ratePrompt.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -76,7 +80,7 @@ var initCmd = &cobra.Command{ if rateInput != "" { hourlyRate, err = strconv.ParseFloat(rateInput, 64) if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing hourly rate: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing hourly rate: %v", err)) os.Exit(1) } } @@ -88,7 +92,7 @@ var initCmd = &cobra.Command{ descInput, err := descPrompt.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -98,19 +102,23 @@ var initCmd = &cobra.Command{ // Create the .tmporc file err := config.CreateWithTemplate(name, hourlyRate, description) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - fmt.Printf("\n[tmpo] Created .tmporc for project '%s'\n", name) + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project '%s'", name)) if hourlyRate > 0 { - fmt.Printf(" Hourly Rate: $%.2f\n", hourlyRate) + ui.PrintInfo(4, "Hourly Rate", fmt.Sprintf("$%.2f", hourlyRate)) } if description != "" { - fmt.Printf(" Description: %s\n", description) + ui.PrintInfo(4, "Description", description) } - fmt.Println("\nYou can edit .tmporc to customize your project settings.") + fmt.Println() + ui.PrintMuted(0, "You can edit .tmporc to customize your project settings.") + + ui.NewlineBelow() }, } diff --git a/cmd/log.go b/cmd/log.go index 6672f24..801cdf9 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -6,6 +6,7 @@ import ( "time" "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" "github.com/spf13/cobra" ) @@ -21,10 +22,12 @@ var logCmd = &cobra.Command{ 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 { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -42,7 +45,7 @@ var logCmd = &cobra.Command{ 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) @@ -53,17 +56,18 @@ var logCmd = &cobra.Command{ } if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } if len(entries) == 0 { - fmt.Println("No time entries found.") - + ui.PrintWarning(ui.EmojiWarning, "No time entries found.") + ui.NewlineBelow() return } - fmt.Printf("\n[tmpo] Time Entries (%d total)\n\n", len(entries)) + ui.PrintSuccess(ui.EmojiLog, fmt.Sprintf("Time Entries (%d total)", len(entries))) + fmt.Println() var totalDuration time.Duration currentDate := "" @@ -75,28 +79,31 @@ var logCmd = &cobra.Command{ fmt.Println() } - fmt.Printf("─── %s ───\n", entryDate) + fmt.Println(ui.Muted(fmt.Sprintf("─── %s ───", entryDate))) currentDate = entryDate } duration := entry.Duration() totalDuration += duration - timeRange := entry.StartTime.Format("03:04 PM") + timeRange := entry.StartTime.Format("03:04 PM") + " - " if entry.EndTime != nil { - timeRange += " - " + entry.EndTime.Format("03:04 PM") + timeRange += entry.EndTime.Format("03:04 PM") + " " } else { - timeRange += " - (running)" + timeRange += ui.Warning("(running)") + " " } - fmt.Printf(" %s %-20s %s\n", timeRange, entry.ProjectName, formatDuration(duration)) + fmt.Printf(" %s %-20s %s\n", timeRange, entry.ProjectName, ui.FormatDuration(duration)) if entry.Description != "" { - fmt.Printf(" └─ %s\n", entry.Description) + fmt.Printf(" %s %s\n", ui.Muted("└─"), entry.Description) } } - fmt.Printf("\n─────────────────────────────────────────\n") - fmt.Printf("Total Time: %s\n", formatDuration(totalDuration)) + fmt.Println() + ui.PrintSeparator() + fmt.Printf("%s %s\n", ui.Info("Total Time:"), ui.FormatDuration(totalDuration)) + + ui.NewlineBelow() }, } diff --git a/cmd/manual.go b/cmd/manual.go index 020f99e..542f729 100644 --- a/cmd/manual.go +++ b/cmd/manual.go @@ -9,6 +9,7 @@ import ( "github.com/DylanDevelops/tmpo/internal/config" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -18,7 +19,9 @@ var manualCmd = &cobra.Command{ 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) { - fmt.Println("\n[tmpo] Create Manual Time Entry") + ui.NewlineAbove() + ui.PrintSuccess(ui.EmojiManual, "Create Manual Time Entry") + fmt.Println() defaultProject := detectProjectNameWithSource() @@ -36,7 +39,7 @@ var manualCmd = &cobra.Command{ projectInput, err := projectPrompt.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -46,7 +49,7 @@ var manualCmd = &cobra.Command{ } if projectName == "" { - fmt.Fprintf(os.Stderr, "Error: project name cannot be empty\n") + ui.PrintError(ui.EmojiError, "project name cannot be empty") os.Exit(1) } @@ -57,7 +60,7 @@ var manualCmd = &cobra.Command{ startDateInput, err := startDatePrompt.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -68,7 +71,7 @@ var manualCmd = &cobra.Command{ startTimeStr, err := startTimePrompt.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -81,7 +84,7 @@ var manualCmd = &cobra.Command{ endDateInput, err := endDatePrompt.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -91,7 +94,7 @@ var manualCmd = &cobra.Command{ } if err := validateDate(endDateInput); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -102,12 +105,12 @@ var manualCmd = &cobra.Command{ endTimeStr, err := endTimePrompt.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } if err := validateEndDateTime(startDateInput, startTimeStr, endDateInput, endTimeStr); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -117,19 +120,19 @@ var manualCmd = &cobra.Command{ description, err := descriptionPrompt.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } startTime, err := parseDateTime(startDateInput, startTimeStr) if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing start time: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) os.Exit(1) } endTime, err := parseDateTime(endDateInput, endTimeStr) if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing end time: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) os.Exit(1) } @@ -140,30 +143,31 @@ var manualCmd = &cobra.Command{ db, err := storage.Initialize() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + 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 { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } duration := entry.Duration() - fmt.Printf("\n[tmpo] Created manual entry for '%s'\n", entry.ProjectName) - fmt.Printf(" Start: %s\n", startTime.Format("Jan 2, 2006 at 3:04 PM")) - fmt.Printf(" End: %s\n", endTime.Format("Jan 2, 2006 at 3:04 PM")) - fmt.Printf(" Duration: %s\n", formatDuration(duration)) + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created manual entry for '%s'", entry.ProjectName)) + ui.PrintInfo(4, "Start", startTime.Format("Jan 2, 2006 at 3:04 PM")) + ui.PrintInfo(4, "End", endTime.Format("Jan 2, 2006 at 3:04 PM")) + ui.PrintInfo(4, "Duration", ui.FormatDuration(duration)) if entry.HourlyRate != nil { earnings := duration.Hours() * *entry.HourlyRate - fmt.Printf(" Hourly Rate: $%.2f\n", *entry.HourlyRate) - fmt.Printf(" Estimated Earnings: $%.2f\n", earnings) + fmt.Printf(" %s %s\n", ui.Info("Hourly Rate:"), fmt.Sprintf("$%.2f", *entry.HourlyRate)) + fmt.Printf(" %s %s\n", ui.Info("Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", earnings))) } - fmt.Println() + ui.NewlineBelow() }, } diff --git a/cmd/start.go b/cmd/start.go index 6cb33ee..889b69a 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -7,6 +7,7 @@ import ( "github.com/DylanDevelops/tmpo/internal/config" "github.com/DylanDevelops/tmpo/internal/project" "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" "github.com/spf13/cobra" ) @@ -15,10 +16,11 @@ var startCmd = &cobra.Command{ 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 { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -26,22 +28,20 @@ var startCmd = &cobra.Command{ running, err := db.GetRunningEntry() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } if running != nil { - fmt.Fprintf(os.Stderr, "Error: Already tracking time for `%s`\n", running.ProjectName) - fmt.Println("Use 'tmpo stop' to stop the current session first.") - + 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 := DetectProjectName() if err != nil { - fmt.Fprintf(os.Stderr, "Error detecting project: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) os.Exit(1) } @@ -58,24 +58,25 @@ var startCmd = &cobra.Command{ entry, err := db.CreateEntry(projectName, description, hourlyRate) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - fmt.Printf("[tmpo] Started tracking time for '%s'\n", entry.ProjectName) + ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Started tracking time for '%s'", entry.ProjectName)) if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { - fmt.Println(" Config Source: .tmporc") + ui.PrintInfo(4, "Config Source", ".tmporc") } else if project.IsInGitRepo() { - fmt.Println(" Config Source: git repository") + ui.PrintInfo(4, "Config Source", "git repository") } else { - fmt.Println(" Config Source: directory name") + ui.PrintInfo(4, "Config Source", "directory name") } if description != "" { - fmt.Printf(" Description: %s\n", description) + ui.PrintInfo(4, "Description", description) } + + ui.NewlineBelow() }, } diff --git a/cmd/stats.go b/cmd/stats.go index 5ec7981..15e92ee 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -6,6 +6,7 @@ import ( "time" "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" "github.com/spf13/cobra" ) @@ -19,10 +20,11 @@ var statsCmd = &cobra.Command{ 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 { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -48,20 +50,17 @@ var statsCmd = &cobra.Command{ } else { entries, err := db.GetEntries(0) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } ShowAllTimeStats(entries, db) - return } entries, err := db.GetEntriesByDateRange(start, end) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -87,8 +86,8 @@ var statsCmd = &cobra.Command{ // may be undefined (NaN/Inf). All output is produced using fmt. func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { if len(entries) == 0 { - fmt.Printf("No entries for %s.\n", periodName) - + ui.PrintWarning(ui.EmojiWarning, fmt.Sprintf("No entries for %s.", periodName)) + ui.NewlineBelow() return } @@ -111,24 +110,27 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { } } - fmt.Printf("\n[tmpo] Stats for %s\n\n", periodName) - fmt.Printf(" Total Time: %s (%.2f hours)\n", formatDuration(totalDuration), totalDuration.Hours()) - fmt.Printf(" Total Entries: %d\n", len(entries)) + ui.PrintSuccess(ui.EmojiStats, fmt.Sprintf("Stats for %s", periodName)) + fmt.Println() + ui.PrintInfo(4, "Total Time", fmt.Sprintf("%s (%.2f hours)", ui.FormatDuration(totalDuration), totalDuration.Hours())) + ui.PrintInfo(4, "Total Entries", fmt.Sprintf("%d", len(entries))) if hasAnyEarnings { - fmt.Printf(" Total Estimated Earnings: $%.2f\n", totalEarnings) + fmt.Printf(" %s %s\n", ui.Info("Total Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", totalEarnings))) } fmt.Println() - fmt.Println(" By Project:") + ui.PrintInfo(4, "By Project", "") for project, duration := range projectStats { percentage := (duration.Seconds() / totalDuration.Seconds()) * 100 - fmt.Printf(" %-20s %s (%.1f%%)\n", project, formatDuration(duration), percentage) + fmt.Printf(" %-20s %s (%.1f%%)\n", project, ui.FormatDuration(duration), percentage) if earnings, ok := projectEarnings[project]; ok && earnings > 0 { - fmt.Printf(" └─ Estimated Earnings: $%.2f\n", earnings) + fmt.Printf(" %s %s\n", ui.Muted("└─ Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", earnings))) } } + + ui.NewlineBelow() } // ShowAllTimeStats prints aggregated all-time statistics to standard output. @@ -148,8 +150,8 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { // output is produced using fmt. func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { if len(entries) == 0 { - fmt.Println("No entries found.") - + ui.PrintWarning(ui.EmojiWarning, "No entries found.") + ui.NewlineBelow() return } @@ -174,25 +176,27 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { projects, _ := db.GetAllProjects() - fmt.Printf("\n[tmpo] All-Time Statistics\n") - fmt.Printf(" Total Time: %s (%.2f hours)\n", formatDuration(totalDuration), totalDuration.Hours()) - fmt.Printf(" Total Entries: %d\n", len(entries)) - fmt.Printf(" Projects Tracked: %d\n", len(projects)) + ui.PrintSuccess(ui.EmojiStats, "All-Time Statistics") + ui.PrintInfo(4, "Total Time", fmt.Sprintf("%s (%.2f hours)", ui.FormatDuration(totalDuration), totalDuration.Hours())) + ui.PrintInfo(4, "Total Entries", fmt.Sprintf("%d", len(entries))) + ui.PrintInfo(4, "Projects Tracked", fmt.Sprintf("%d", len(projects))) if hasAnyEarnings { - fmt.Printf(" Total Estimated Earnings: $%.2f\n", totalEarnings) + fmt.Printf(" %s %s\n", ui.Info("Total Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", totalEarnings))) } fmt.Println() - fmt.Println(" By Project:") + ui.PrintInfo(4, "By Project", "") for project, duration := range projectStats { percentage := (duration.Seconds() / totalDuration.Seconds()) * 100 - fmt.Printf(" %-20s %s (%.1f%%)\n", project, formatDuration(duration), percentage) + fmt.Printf(" %-20s %s (%.1f%%)\n", project, ui.FormatDuration(duration), percentage) if earnings, ok := projectEarnings[project]; ok && earnings > 0 { - fmt.Printf(" └─ Estimated Earnings: $%.2f\n", earnings) + fmt.Printf(" %s %s\n", ui.Muted("└─ Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", earnings))) } } + + ui.NewlineBelow() } func init() { diff --git a/cmd/status.go b/cmd/status.go index a71d037..3b84d7a 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -6,6 +6,7 @@ import ( "time" "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" "github.com/spf13/cobra" ) @@ -15,38 +16,41 @@ var statusCmd = &cobra.Command{ 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 { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - + defer db.Close() running, err := db.GetRunningEntry() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } if running == nil { - fmt.Println("[tmpo] Not currently tracking time") - fmt.Println("\nUse 'tmpo start' to begin tracking") - + 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) - fmt.Printf("[tmpo] Currently tracking: %s\n", running.ProjectName) - fmt.Printf(" Started: %s\n", running.StartTime.Format("3:04 PM")) - fmt.Printf(" Duration: %s\n", formatDuration(duration)) + ui.PrintSuccess(ui.EmojiStatus, fmt.Sprintf("Currently tracking: %s", running.ProjectName)) + ui.PrintInfo(4, "Started", running.StartTime.Format("3:04 PM")) + ui.PrintInfo(4, "Duration", ui.FormatDuration(duration)) if running.Description != "" { - fmt.Printf(" Description: %s\n", running.Description) + ui.PrintInfo(4, "Description", running.Description) } + + ui.NewlineBelow() }, } diff --git a/cmd/stop.go b/cmd/stop.go index 8251239..020aacf 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -6,6 +6,7 @@ import ( "time" "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" "github.com/spf13/cobra" ) @@ -15,10 +16,11 @@ var stopCmd = &cobra.Command{ 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 { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } @@ -26,48 +28,28 @@ var stopCmd = &cobra.Command{ running, err := db.GetRunningEntry() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - - if running == nil { - fmt.Println("No active time tracking session.") + if running == nil { + ui.PrintWarning(ui.EmojiWarning, "No active time tracking session.") os.Exit(0) } err = db.StopEntry(running.ID) if(err != nil) { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } duration := time.Since(running.StartTime) - fmt.Printf("[tmpo] Stopped tracking '%s'\n", running.ProjectName) - fmt.Printf(" Total Duration: %s\n", formatDuration(duration)) - }, -} - -// formatDuration formats d into a concise, human-readable string using hours, minutes and seconds. -// It returns "h m s" when the duration is at least one hour, "m s" when the duration -// is at least one minute but less than an hour, and "s" for durations under one minute. -// Hours, minutes and seconds are derived from d using integer truncation (no fractional parts). -// This function is intended for non-negative durations; behavior for negative durations is unspecified. -func formatDuration(d time.Duration) string { - hours := int(d.Hours()) - minutes := int(d.Minutes()) % 60 - seconds := int(d.Seconds()) % 60 + ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Stopped tracking '%s'", running.ProjectName)) + ui.PrintInfo(4, "Total Duration", ui.FormatDuration(duration)) - if hours > 0 { - return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds) - } else if minutes > 0 { - return fmt.Sprintf("%dm %ds", minutes, seconds) - } - - return fmt.Sprintf("%ds", seconds) + ui.NewlineBelow() + }, } func init() { diff --git a/cmd/version.go b/cmd/version.go index bdfcc41..ace92c6 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/DylanDevelops/tmpo/internal/ui" "github.com/spf13/cobra" ) @@ -22,11 +23,13 @@ var versionCmd = &cobra.Command{ // GetVersionOutput returns the formatted version string used by both // the version subcommand and the -v/--version flags func GetVersionOutput() string { - return fmt.Sprintf("\ntmpo version %s %s\n%s\n\n", Version, GetFormattedDate(Date), GetChangelogUrl(Version)) + versionLine := fmt.Sprintf("tmpo version %s %s", ui.Success(Version), ui.Muted(GetFormattedDate(Date))) + changelogLine := ui.Muted(GetChangelogUrl(Version)) + return fmt.Sprintf("\n%s\n%s\n\n", versionLine, changelogLine) } // GetFormattedDate parses inputDate as an RFC3339 timestamp and returns the date -// formatted as "YYYY-MM-DD" wrapped in parentheses (for example "(01-02-2006)"). +// formatted as "MM-DD-YYYY" wrapped in parentheses (for example "(01-02-2006)"). // If inputDate is empty or cannot be parsed as RFC3339, it returns an empty string. func GetFormattedDate(inputDate string) string { if inputDate == "" { diff --git a/internal/ui/format.go b/internal/ui/format.go new file mode 100644 index 0000000..707d99d --- /dev/null +++ b/internal/ui/format.go @@ -0,0 +1,130 @@ +package ui + +import ( + "fmt" + "os" + "time" +) + +// ANSI Color Constants +const ( + ColorReset = "\033[0m" + ColorGreen = "\033[32m" // Success + ColorRed = "\033[31m" // Errors + ColorBlue = "\033[34m" // Info + ColorYellow = "\033[33m" // Warnings + ColorCyan = "\033[36m" // Highlights + ColorGray = "\033[90m" // Muted text +) + +// Emoji Constants +const ( + EmojiStart = "✨" + EmojiStop = "🛑" + EmojiStatus = "⏱️" + EmojiStats = "📊" + EmojiLog = "📝" + EmojiManual = "✍️" + EmojiInit = "⚙️" + EmojiExport = "📤" + EmojiSuccess = "✅" + EmojiError = "❌" + EmojiWarning = "⚠️" + EmojiInfo = "ℹ️" +) + +// Colored output functions that return colored strings +func Success(message string) string { + return ColorGreen + message + ColorReset +} + +func Error(message string) string { + return ColorRed + message + ColorReset +} + +func Info(message string) string { + return ColorBlue + message + ColorReset +} + +func Warning(message string) string { + return ColorYellow + message + ColorReset +} + +func Muted(message string) string { + return ColorGray + message + ColorReset +} + +// PrintSuccess prints a success message with emoji and color to stdout +func PrintSuccess(emoji, message string) { + fmt.Println(Success(fmt.Sprintf("%s %s", emoji, message))) +} + +// PrintError prints an error message with emoji and color to stderr +func PrintError(emoji, message string) { + fmt.Fprintf(os.Stderr, "%s\n", Error(fmt.Sprintf("%s %s", emoji, message))) +} + +// PrintWarning prints a warning message with emoji and color to stdout +func PrintWarning(emoji, message string) { + fmt.Println(Warning(fmt.Sprintf("%s %s", emoji, message))) +} + +// PrintInfo prints an info line with proper indentation and color +// indent specifies the number of spaces (typically 4 or 8) +// If value is empty, only label is printed +func PrintInfo(indent int, label, value string) { + spaces := "" + for i := 0; i < indent; i++ { + spaces += " " + } + + if value != "" { + fmt.Printf("%s%s: %s\n", spaces, Info(label), value) + } else { + fmt.Printf("%s%s\n", spaces, Info(label)) + } +} + +// PrintMuted prints muted (gray) text with optional indentation +func PrintMuted(indent int, message string) { + spaces := "" + for i := 0; i < indent; i++ { + spaces += " " + } + fmt.Printf("%s%s\n", spaces, Muted(message)) +} + +// PrintSeparator prints a subtle horizontal separator line +func PrintSeparator() { + fmt.Println(Muted("─────────────────────────────────────────")) +} + +// NewlineAbove prints a single newline before output +// This creates visual separation from the user's command input +func NewlineAbove() { + fmt.Println() +} + +// NewlineBelow prints a single newline after output +func NewlineBelow() { + fmt.Println() +} + +// FormatDuration formats d into a concise, human-readable string using hours, minutes and seconds. +// It returns "h m s" when the duration is at least one hour, "m s" when the duration +// is at least one minute but less than an hour, and "s" for durations under one minute. +// Hours, minutes and seconds are derived from d using integer truncation (no fractional parts). +// This function is intended for non-negative durations; behavior for negative durations is unspecified. +func FormatDuration(d time.Duration) string { + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + if hours > 0 { + return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds) + } else if minutes > 0 { + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + + return fmt.Sprintf("%ds", seconds) +} From 861fdd1d2b30c9fd002187086012800b69af330f Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Tue, 16 Dec 2025 14:56:12 -0700 Subject: [PATCH 2/3] refactored name --- internal/ui/{format.go => ui.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/ui/{format.go => ui.go} (100%) diff --git a/internal/ui/format.go b/internal/ui/ui.go similarity index 100% rename from internal/ui/format.go rename to internal/ui/ui.go From 473ae543801a057dbb41a7f85008a5b75ea1d4b9 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Tue, 16 Dec 2025 15:09:58 -0700 Subject: [PATCH 3/3] Update earnings labels in manual and stats commands Replaces 'Estimated Earnings' with 'Earnings' in output messages for consistency and clarity across manual and stats commands. --- cmd/manual.go | 2 +- cmd/stats.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/manual.go b/cmd/manual.go index 542f729..0a6a380 100644 --- a/cmd/manual.go +++ b/cmd/manual.go @@ -164,7 +164,7 @@ var manualCmd = &cobra.Command{ if entry.HourlyRate != nil { earnings := duration.Hours() * *entry.HourlyRate fmt.Printf(" %s %s\n", ui.Info("Hourly Rate:"), fmt.Sprintf("$%.2f", *entry.HourlyRate)) - fmt.Printf(" %s %s\n", ui.Info("Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", earnings))) + fmt.Printf(" %s %s\n", ui.Info("Earnings:"), fmt.Sprintf("$%.2f", earnings)) } ui.NewlineBelow() diff --git a/cmd/stats.go b/cmd/stats.go index 15e92ee..f5cd1e5 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -116,7 +116,7 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { ui.PrintInfo(4, "Total Entries", fmt.Sprintf("%d", len(entries))) if hasAnyEarnings { - fmt.Printf(" %s %s\n", ui.Info("Total Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", totalEarnings))) + ui.PrintInfo(4, "Earnings", fmt.Sprintf("$%.2f", totalEarnings)) } fmt.Println() @@ -126,7 +126,7 @@ func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { fmt.Printf(" %-20s %s (%.1f%%)\n", project, ui.FormatDuration(duration), percentage) if earnings, ok := projectEarnings[project]; ok && earnings > 0 { - fmt.Printf(" %s %s\n", ui.Muted("└─ Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", earnings))) + fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), fmt.Sprintf("$%.2f", earnings)) } } @@ -182,7 +182,7 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { ui.PrintInfo(4, "Projects Tracked", fmt.Sprintf("%d", len(projects))) if hasAnyEarnings { - fmt.Printf(" %s %s\n", ui.Info("Total Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", totalEarnings))) + ui.PrintInfo(4, "Earnings", fmt.Sprintf("$%.2f", totalEarnings)) } fmt.Println() @@ -192,7 +192,7 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { fmt.Printf(" %-20s %s (%.1f%%)\n", project, ui.FormatDuration(duration), percentage) if earnings, ok := projectEarnings[project]; ok && earnings > 0 { - fmt.Printf(" %s %s\n", ui.Muted("└─ Estimated Earnings:"), ui.Success(fmt.Sprintf("$%.2f", earnings))) + fmt.Printf(" %s %s\n", ui.Muted("└─ Earnings:"), fmt.Sprintf("$%.2f", earnings)) } }