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..0a6a380 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("Earnings:"), 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..f5cd1e5 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) + ui.PrintInfo(4, "Earnings", 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("└─ Earnings:"), 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) + ui.PrintInfo(4, "Earnings", 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("└─ Earnings:"), 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/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..707d99d --- /dev/null +++ b/internal/ui/ui.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) +}