summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 22:48:26 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 22:48:26 +0200
commit212182216ce42e51da21541f39d485ae11fe5c4c (patch)
treecc3572b3d948aa73c1bb5954e7a01ebdcae47eb3
parent0906167aaed5dfab38cefe3fd001187a9c44006e (diff)
Task 352: add timer cobra subcommands
-rw-r--r--internal/cli/root.go1
-rw-r--r--internal/cli/timer.go209
-rw-r--r--internal/cli/timer_test.go84
3 files changed, 294 insertions, 0 deletions
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 6029e81..53b7758 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -49,6 +49,7 @@ func NewRootCmd() *cobra.Command {
cmd.Flags().BoolVar(&showVersion, "version", false, "Print version and exit")
cmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file")
+ cmd.AddCommand(newTimerCmd())
return cmd
}
diff --git a/internal/cli/timer.go b/internal/cli/timer.go
new file mode 100644
index 0000000..aafcda0
--- /dev/null
+++ b/internal/cli/timer.go
@@ -0,0 +1,209 @@
+package cli
+
+import (
+ "errors"
+ "fmt"
+ "math/rand/v2"
+ "strconv"
+ "strings"
+
+ "codeberg.org/snonux/timr/internal/ascii"
+ "codeberg.org/snonux/timr/internal/live"
+ timrTimer "codeberg.org/snonux/timr/internal/timer"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/spf13/cobra"
+)
+
+func newTimerCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "timer",
+ Short: "Stopwatch timer operations",
+ }
+
+ cmd.AddCommand(newTimerStartCmd())
+ cmd.AddCommand(newTimerStopCmd())
+ cmd.AddCommand(newTimerContinueCmd())
+ cmd.AddCommand(newTimerResetCmd())
+ cmd.AddCommand(newTimerStatusCmd())
+ cmd.AddCommand(newTimerPromptCmd())
+ cmd.AddCommand(newTimerTrackCmd())
+ cmd.AddCommand(newTimerLiveCmd())
+
+ return cmd
+}
+
+func newTimerStartCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "start",
+ Short: "Start the timer",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ rawStatus, err := timrTimer.GetRawStatus()
+ if err != nil {
+ return err
+ }
+ status, err := strconv.ParseFloat(rawStatus, 64)
+ if err != nil {
+ return err
+ }
+
+ output, err := timrTimer.StartTimer(status > 0)
+ if err != nil {
+ return err
+ }
+ return printOutput(cmd, output)
+ },
+ }
+}
+
+func newTimerStopCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "stop",
+ Short: "Stop the timer",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ output, err := timrTimer.StopTimer()
+ if err != nil {
+ return err
+ }
+ return printOutput(cmd, output)
+ },
+ }
+}
+
+func newTimerContinueCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "continue",
+ Short: "Continue a stopped timer",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ rawStatus, err := timrTimer.GetRawStatus()
+ if err != nil {
+ return err
+ }
+ status, err := strconv.ParseFloat(rawStatus, 64)
+ if err != nil {
+ return err
+ }
+
+ output := "Timer is at 0, cannot continue."
+ if status > 0 {
+ output, err = timrTimer.StartTimer(true)
+ if err != nil {
+ return err
+ }
+ }
+
+ return printOutput(cmd, output)
+ },
+ }
+}
+
+func newTimerResetCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "reset",
+ Short: "Reset the timer",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ output, err := timrTimer.ResetTimer()
+ if err != nil {
+ return err
+ }
+ return printOutput(cmd, output)
+ },
+ }
+}
+
+func newTimerStatusCmd() *cobra.Command {
+ var raw bool
+ var rawMinutes bool
+
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "Show timer status",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if raw && rawMinutes {
+ return errors.New("only one of --raw or --raw-minutes can be set")
+ }
+
+ var (
+ output string
+ err error
+ )
+
+ switch {
+ case raw:
+ output, err = timrTimer.GetRawStatus()
+ case rawMinutes:
+ output, err = timrTimer.GetRawMinutesStatus()
+ default:
+ output, err = timrTimer.GetStatus()
+ }
+ if err != nil {
+ return err
+ }
+
+ return printOutput(cmd, output)
+ },
+ }
+
+ cmd.Flags().BoolVar(&raw, "raw", false, "Show elapsed time in seconds")
+ cmd.Flags().BoolVar(&rawMinutes, "raw-minutes", false, "Show elapsed time in minutes")
+ return cmd
+}
+
+func newTimerPromptCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "prompt",
+ Short: "Show prompt-friendly timer status",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ output, err := timrTimer.GetPromptStatus()
+ if err != nil {
+ return err
+ }
+ return printOutput(cmd, output)
+ },
+ }
+}
+
+func newTimerTrackCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "track <description>",
+ Short: "Track elapsed time to Taskwarrior and reset timer",
+ Args: cobra.MinimumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ description := strings.Join(args, " ")
+ output, err := timrTimer.TrackTime(description)
+ if err != nil {
+ return err
+ }
+ return printOutput(cmd, output)
+ },
+ }
+}
+
+func newTimerLiveCmd() *cobra.Command {
+ var font string
+
+ cmd := &cobra.Command{
+ Use: "live",
+ Short: "Launch interactive live timer view",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ selectedFont := strings.TrimSpace(font)
+ if selectedFont == "" {
+ selectedFont = ascii.AllFonts[rand.IntN(len(ascii.AllFonts))]
+ }
+
+ program := tea.NewProgram(live.NewModel(selectedFont))
+ return program.Start()
+ },
+ }
+
+ cmd.Flags().StringVarP(&font, "font", "f", "", "Font for live timer (doom, mono12, rebel, ansi, ansiShadow)")
+ return cmd
+}
+
+func printOutput(cmd *cobra.Command, output string) error {
+ if output == "" {
+ return nil
+ }
+
+ _, err := fmt.Fprintln(cmd.OutOrStdout(), output)
+ return err
+}
diff --git a/internal/cli/timer_test.go b/internal/cli/timer_test.go
new file mode 100644
index 0000000..dead994
--- /dev/null
+++ b/internal/cli/timer_test.go
@@ -0,0 +1,84 @@
+package cli
+
+import (
+ "bytes"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ timrTimer "codeberg.org/snonux/timr/internal/timer"
+)
+
+func TestTimerStartAndStopCommands(t *testing.T) {
+ setupTimerState(t)
+
+ var startOut bytes.Buffer
+ startCmd := NewRootCmd()
+ startCmd.SetOut(&startOut)
+ startCmd.SetErr(&startOut)
+ startCmd.SetArgs([]string{"timer", "start"})
+ if err := startCmd.Execute(); err != nil {
+ t.Fatalf("timer start execute error = %v", err)
+ }
+ if !strings.Contains(startOut.String(), "Timer started.") {
+ t.Fatalf("timer start output = %q", startOut.String())
+ }
+
+ var stopOut bytes.Buffer
+ stopCmd := NewRootCmd()
+ stopCmd.SetOut(&stopOut)
+ stopCmd.SetErr(&stopOut)
+ stopCmd.SetArgs([]string{"timer", "stop"})
+ if err := stopCmd.Execute(); err != nil {
+ t.Fatalf("timer stop execute error = %v", err)
+ }
+ if !strings.Contains(stopOut.String(), "Timer stopped.") {
+ t.Fatalf("timer stop output = %q", stopOut.String())
+ }
+}
+
+func TestTimerContinueAtZero(t *testing.T) {
+ setupTimerState(t)
+
+ var out bytes.Buffer
+ cmd := NewRootCmd()
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{"timer", "continue"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("timer continue execute error = %v", err)
+ }
+
+ if !strings.Contains(out.String(), "Timer is at 0, cannot continue.") {
+ t.Fatalf("timer continue output = %q", out.String())
+ }
+}
+
+func TestTimerStatusFlagConflict(t *testing.T) {
+ setupTimerState(t)
+
+ var out bytes.Buffer
+ cmd := NewRootCmd()
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{"timer", "status", "--raw", "--raw-minutes"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("timer status conflict error = nil, want error")
+ }
+ if !strings.Contains(err.Error(), "--raw") {
+ t.Fatalf("timer status conflict error = %v", err)
+ }
+}
+
+func setupTimerState(t *testing.T) {
+ t.Helper()
+
+ tempDir := t.TempDir()
+ timrTimer.SetStateFilePathOverride(filepath.Join(tempDir, ".timr_state"))
+ t.Cleanup(func() {
+ timrTimer.SetStateFilePathOverride("")
+ })
+}