summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/root.go59
-rw-r--r--internal/cli/root_test.go122
-rw-r--r--internal/cli/timer.go28
-rw-r--r--internal/cli/timer_test.go2
-rw-r--r--internal/cli/tui.go11
-rw-r--r--internal/cli/tui_test.go5
-rw-r--r--internal/cli/work.go46
-rw-r--r--internal/cli/work_test.go44
8 files changed, 281 insertions, 36 deletions
diff --git a/internal/cli/root.go b/internal/cli/root.go
index faba592..8b5ad09 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -2,10 +2,14 @@ package cli
import (
"context"
+ "errors"
"fmt"
+ "strings"
+ "time"
- timr "codeberg.org/snonux/timr/internal"
- "codeberg.org/snonux/timr/internal/config"
+ timesamurai "codeberg.org/snonux/timesamurai/internal"
+ "codeberg.org/snonux/timesamurai/internal/config"
+ "codeberg.org/snonux/timesamurai/internal/worktime"
"github.com/spf13/cobra"
)
@@ -20,9 +24,10 @@ func Execute() error {
func NewRootCmd() *cobra.Command {
var configPath string
var showVersion bool
+ var checkDBIntegrity bool
cmd := &cobra.Command{
- Use: "timr",
+ Use: "timesamurai",
Short: "Track time from your terminal",
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
@@ -40,15 +45,19 @@ func NewRootCmd() *cobra.Command {
},
RunE: func(cmd *cobra.Command, args []string) error {
if showVersion {
- _, err := fmt.Fprintln(cmd.OutOrStdout(), timr.Version)
+ _, err := fmt.Fprintln(cmd.OutOrStdout(), timesamurai.Version)
return err
}
+ if checkDBIntegrity {
+ return runDBIntegrityCheck(cmd)
+ }
return cmd.Help()
},
}
cmd.Flags().BoolVar(&showVersion, "version", false, "Print version and exit")
+ cmd.Flags().BoolVar(&checkDBIntegrity, "check-db-integrity", false, "Validate worktime database integrity and exit")
cmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file")
cmd.AddCommand(newTimerCmd())
cmd.AddCommand(newWorkCmd())
@@ -81,3 +90,45 @@ func currentConfig(cmd *cobra.Command) config.Config {
}
return cfg
}
+
+func runDBIntegrityCheck(cmd *cobra.Command) error {
+ cfg := currentConfig(cmd)
+ entries, err := worktime.LoadAll(cfg.WorktimeDBDir)
+ if err != nil {
+ return err
+ }
+
+ issues := worktime.CheckEntriesIntegrity(entries, worktime.DefaultMaxSessionSpan)
+ openSessions := worktime.OpenSessions(entries)
+
+ lines := make([]string, 0, len(issues)+len(openSessions)+2)
+ if len(issues) == 0 {
+ lines = append(lines, "Database integrity check passed.")
+ } else {
+ lines = append(lines, fmt.Sprintf("Database integrity check found %d issue(s):", len(issues)))
+ for idx, issue := range issues {
+ lines = append(lines, fmt.Sprintf("%d. %s", idx+1, issue.String()))
+ }
+ }
+
+ if len(openSessions) > 0 {
+ lines = append(lines, fmt.Sprintf("Warning: currently logged in (%d open session(s)):", len(openSessions)))
+ for _, session := range openSessions {
+ lines = append(lines, fmt.Sprintf(
+ "- category=%s source=%s since=%s",
+ session.Category,
+ session.Login.Source,
+ time.Unix(session.Login.Epoch, 0).Format("2006-01-02 15:04:05"),
+ ))
+ }
+ }
+
+ _, printErr := fmt.Fprintln(cmd.OutOrStdout(), strings.Join(lines, "\n"))
+ if printErr != nil {
+ return printErr
+ }
+ if len(issues) > 0 {
+ return errors.New("database integrity check failed")
+ }
+ return nil
+}
diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go
index 2b12514..e1f74b1 100644
--- a/internal/cli/root_test.go
+++ b/internal/cli/root_test.go
@@ -7,8 +7,10 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
- timr "codeberg.org/snonux/timr/internal"
+ timesamurai "codeberg.org/snonux/timesamurai/internal"
+ "codeberg.org/snonux/timesamurai/internal/worktime"
)
func TestRootVersionFlag(t *testing.T) {
@@ -22,8 +24,8 @@ func TestRootVersionFlag(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if strings.TrimSpace(out.String()) != timr.Version {
- t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timr.Version)
+ if strings.TrimSpace(out.String()) != timesamurai.Version {
+ t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timesamurai.Version)
}
}
@@ -96,8 +98,8 @@ func TestVersionSkipsConfigLoading(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if strings.TrimSpace(out.String()) != timr.Version {
- t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timr.Version)
+ if strings.TrimSpace(out.String()) != timesamurai.Version {
+ t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timesamurai.Version)
}
}
@@ -124,3 +126,113 @@ func TestRootUsesDefaultConfigWhenNoFileExists(t *testing.T) {
t.Fatalf("WorktimeDBDir = %q, want %q", cfg.WorktimeDBDir, wantDir)
}
}
+
+func TestRootCheckDBIntegrityPasses(t *testing.T) {
+ dbDir := t.TempDir()
+ host := "host-a"
+
+ if _, err := worktime.Login(dbDir, host, "work", time.Unix(100, 0), ""); err != nil {
+ t.Fatalf("Login() error = %v", err)
+ }
+ if _, err := worktime.Logout(dbDir, host, "work", time.Unix(200, 0), ""); err != nil {
+ t.Fatalf("Logout() error = %v", err)
+ }
+
+ cfgPath := writeRootConfig(t, dbDir, host)
+
+ var out bytes.Buffer
+ cmd := NewRootCmd()
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{"--config", cfgPath, "--check-db-integrity"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v (output: %q)", err, out.String())
+ }
+ if !strings.Contains(out.String(), "Database integrity check passed.") {
+ t.Fatalf("unexpected output: %q", out.String())
+ }
+}
+
+func TestRootCheckDBIntegrityFailsOnIssues(t *testing.T) {
+ dbDir := t.TempDir()
+ host := "host-a"
+
+ db := worktime.Database{
+ Entries: map[string][]worktime.Entry{
+ host: {
+ {
+ Action: "logout",
+ What: "work",
+ Epoch: 100,
+ Source: host,
+ Human: time.Unix(100, 0).Format("Mon 02.01.2006 15:04:05"),
+ },
+ },
+ },
+ }
+ if err := worktime.SaveHost(dbDir, host, db); err != nil {
+ t.Fatalf("SaveHost() error = %v", err)
+ }
+
+ cfgPath := writeRootConfig(t, dbDir, host)
+
+ var out bytes.Buffer
+ cmd := NewRootCmd()
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{"--config", cfgPath, "--check-db-integrity"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("Execute() error = nil, want integrity failure")
+ }
+ if !strings.Contains(err.Error(), "database integrity check failed") {
+ t.Fatalf("Execute() error = %v, want integrity failure", err)
+ }
+ if !strings.Contains(out.String(), "Database integrity check found") {
+ t.Fatalf("unexpected output: %q", out.String())
+ }
+}
+
+func TestRootCheckDBIntegrityWarnsForOpenSession(t *testing.T) {
+ dbDir := t.TempDir()
+ host := "host-a"
+
+ if _, err := worktime.Login(dbDir, host, "work", time.Unix(100, 0), ""); err != nil {
+ t.Fatalf("Login() error = %v", err)
+ }
+
+ cfgPath := writeRootConfig(t, dbDir, host)
+
+ var out bytes.Buffer
+ cmd := NewRootCmd()
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{"--config", cfgPath, "--check-db-integrity"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v (output: %q)", err, out.String())
+ }
+ if !strings.Contains(out.String(), "Database integrity check passed.") {
+ t.Fatalf("unexpected output: %q", out.String())
+ }
+ if !strings.Contains(out.String(), "Warning: currently logged in") {
+ t.Fatalf("expected open-session warning in output: %q", out.String())
+ }
+}
+
+func writeRootConfig(t *testing.T, dbDir, host string) string {
+ t.Helper()
+
+ content := `{
+ "worktime_db_dir": "` + dbDir + `",
+ "hostname": "` + host + `"
+}
+`
+ path := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("write config file: %v", err)
+ }
+ return path
+}
diff --git a/internal/cli/timer.go b/internal/cli/timer.go
index 37e50d0..e9889c4 100644
--- a/internal/cli/timer.go
+++ b/internal/cli/timer.go
@@ -8,10 +8,10 @@ import (
"strings"
"time"
- "codeberg.org/snonux/timr/internal/ascii"
- timrTimer "codeberg.org/snonux/timr/internal/timer"
- tuiapp "codeberg.org/snonux/timr/internal/tui"
- "codeberg.org/snonux/timr/internal/worktime"
+ "codeberg.org/snonux/timesamurai/internal/ascii"
+ timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer"
+ tuiapp "codeberg.org/snonux/timesamurai/internal/tui"
+ "codeberg.org/snonux/timesamurai/internal/worktime"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
@@ -44,7 +44,7 @@ func newTimerStartCmd() *cobra.Command {
return err
}
- output, err := timrTimer.StartTimer(hasElapsed)
+ output, err := timesamuraiTimer.StartTimer(hasElapsed)
if err != nil {
return err
}
@@ -61,7 +61,7 @@ func newTimerStopCmd() *cobra.Command {
Use: "stop",
Short: "Stop the timer",
RunE: func(cmd *cobra.Command, args []string) error {
- output, err := timrTimer.StopTimer()
+ output, err := timesamuraiTimer.StopTimer()
if err != nil {
return fmt.Errorf("stop timer: %w", err)
}
@@ -85,7 +85,7 @@ func newTimerContinueCmd() *cobra.Command {
output := "Timer is at 0, cannot continue."
if hasElapsed {
- output, err = timrTimer.StartTimer(true)
+ output, err = timesamuraiTimer.StartTimer(true)
if err != nil {
return err
}
@@ -101,7 +101,7 @@ func newTimerResetCmd() *cobra.Command {
Use: "reset",
Short: "Reset the timer",
RunE: func(cmd *cobra.Command, args []string) error {
- output, err := timrTimer.ResetTimer()
+ output, err := timesamuraiTimer.ResetTimer()
if err != nil {
return fmt.Errorf("reset timer: %w", err)
}
@@ -129,11 +129,11 @@ func newTimerStatusCmd() *cobra.Command {
switch {
case raw:
- output, err = timrTimer.GetRawStatus()
+ output, err = timesamuraiTimer.GetRawStatus()
case rawMinutes:
- output, err = timrTimer.GetRawMinutesStatus()
+ output, err = timesamuraiTimer.GetRawMinutesStatus()
default:
- output, err = timrTimer.GetStatus()
+ output, err = timesamuraiTimer.GetStatus()
}
if err != nil {
return err
@@ -153,7 +153,7 @@ func newTimerPromptCmd() *cobra.Command {
Use: "prompt",
Short: "Show prompt-friendly timer status",
RunE: func(cmd *cobra.Command, args []string) error {
- output, err := timrTimer.GetPromptStatus()
+ output, err := timesamuraiTimer.GetPromptStatus()
if err != nil {
return fmt.Errorf("get prompt timer status: %w", err)
}
@@ -169,7 +169,7 @@ func newTimerTrackCmd() *cobra.Command {
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
description := strings.Join(args, " ")
- output, err := timrTimer.TrackTime(description)
+ output, err := timesamuraiTimer.TrackTime(description)
if err != nil {
return fmt.Errorf("track timer entry %q: %w", description, err)
}
@@ -242,7 +242,7 @@ func syncWorktimeWithTimer(cmd *cobra.Command, start bool) error {
}
func timerHasElapsed() (bool, error) {
- rawStatus, err := timrTimer.GetRawStatus()
+ rawStatus, err := timesamuraiTimer.GetRawStatus()
if err != nil {
return false, fmt.Errorf("get raw timer status: %w", err)
}
diff --git a/internal/cli/timer_test.go b/internal/cli/timer_test.go
index 572d5a0..be4391e 100644
--- a/internal/cli/timer_test.go
+++ b/internal/cli/timer_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"time"
- "codeberg.org/snonux/timr/internal/worktime"
+ "codeberg.org/snonux/timesamurai/internal/worktime"
)
func TestTimerStartAndStopCommands(t *testing.T) {
diff --git a/internal/cli/tui.go b/internal/cli/tui.go
index ff4f2aa..474ffc2 100644
--- a/internal/cli/tui.go
+++ b/internal/cli/tui.go
@@ -1,17 +1,19 @@
package cli
import (
- tuiapp "codeberg.org/snonux/timr/internal/tui"
+ tuiapp "codeberg.org/snonux/timesamurai/internal/tui"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
func newTUICmd() *cobra.Command {
- return &cobra.Command{
+ var disco bool
+
+ cmd := &cobra.Command{
Use: "tui",
Short: "Launch full-screen TUI",
RunE: func(cmd *cobra.Command, args []string) error {
- model, err := tuiapp.NewModelWithConfig(currentConfig(cmd))
+ model, err := tuiapp.NewModelWithConfigAndDisco(currentConfig(cmd), disco)
if err != nil {
return err
}
@@ -19,4 +21,7 @@ func newTUICmd() *cobra.Command {
return program.Start()
},
}
+
+ cmd.Flags().BoolVar(&disco, "disco", false, "Enable disco mode (random theme changes)")
+ return cmd
}
diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go
index 9d8f481..8ec4afd 100644
--- a/internal/cli/tui_test.go
+++ b/internal/cli/tui_test.go
@@ -25,4 +25,9 @@ func TestNewTUICmdMetadata(t *testing.T) {
if cmd.Short == "" {
t.Fatal("Short description should not be empty")
}
+
+ flag := cmd.Flags().Lookup("disco")
+ if flag == nil {
+ t.Fatal("expected --disco flag to be defined")
+ }
}
diff --git a/internal/cli/work.go b/internal/cli/work.go
index 22f0406..d6b989e 100644
--- a/internal/cli/work.go
+++ b/internal/cli/work.go
@@ -10,10 +10,10 @@ import (
"strings"
"time"
- "codeberg.org/snonux/timr/internal/duration"
- "codeberg.org/snonux/timr/internal/timefmt"
- timrTimer "codeberg.org/snonux/timr/internal/timer"
- "codeberg.org/snonux/timr/internal/worktime"
+ "codeberg.org/snonux/timesamurai/internal/duration"
+ "codeberg.org/snonux/timesamurai/internal/timefmt"
+ timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer"
+ "codeberg.org/snonux/timesamurai/internal/worktime"
"github.com/spf13/cobra"
)
@@ -32,6 +32,7 @@ func newWorkCmd() *cobra.Command {
cmd.AddCommand(newWorkLogoutCmd())
cmd.AddCommand(newWorkAddCmd())
cmd.AddCommand(newWorkSubCmd())
+ cmd.AddCommand(newWorkDayOffCmd())
cmd.AddCommand(newWorkUseBufferCmd())
cmd.AddCommand(newWorkReportCmd())
cmd.AddCommand(newWorkStatusCmd())
@@ -211,6 +212,39 @@ func newWorkSubCmd() *cobra.Command {
return cmd
}
+func newWorkDayOffCmd() *cobra.Command {
+ var at string
+ var descr string
+
+ cmd := &cobra.Command{
+ Use: "day-off",
+ Short: "Add an 8-hour day-off entry",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx, err := resolveWorkContext(cmd)
+ if err != nil {
+ return err
+ }
+
+ day, err := parseAtOrNow(at)
+ if err != nil {
+ return err
+ }
+
+ entry, err := worktime.AddDayOff(ctx.dbDir, ctx.host, day, descr)
+ if err != nil {
+ return err
+ }
+
+ dayLabel := time.Unix(entry.Epoch, 0).Format("2006-01-02")
+ return printOutput(cmd, fmt.Sprintf("Added day off: 8h on %s", dayLabel))
+ },
+ }
+
+ cmd.Flags().StringVar(&at, "at", "", "Date/time override (unix, ISO, today, yesterday)")
+ cmd.Flags().StringVarP(&descr, "descr", "d", "", "Description")
+ return cmd
+}
+
func newWorkUseBufferCmd() *cobra.Command {
var at string
var descr string
@@ -407,9 +441,9 @@ func startTimerFromWorkCommand() (string, error) {
if err != nil {
return "", err
}
- return timrTimer.StartTimer(hasElapsed)
+ return timesamuraiTimer.StartTimer(hasElapsed)
}
func stopTimerFromWorkCommand() (string, error) {
- return timrTimer.StopTimer()
+ return timesamuraiTimer.StopTimer()
}
diff --git a/internal/cli/work_test.go b/internal/cli/work_test.go
index 9621bfb..04a1643 100644
--- a/internal/cli/work_test.go
+++ b/internal/cli/work_test.go
@@ -7,8 +7,10 @@ import (
"strconv"
"strings"
"testing"
+ "time"
- timrTimer "codeberg.org/snonux/timr/internal/timer"
+ timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer"
+ "codeberg.org/snonux/timesamurai/internal/worktime"
)
func TestWorkLoginStatusLogoutFlow(t *testing.T) {
@@ -87,7 +89,7 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) {
if err != nil {
t.Fatalf("work login --start-timer error = %v (output: %q)", err, out)
}
- state, err := timrTimer.LoadState()
+ state, err := timesamuraiTimer.LoadState()
if err != nil {
t.Fatalf("LoadState() error = %v", err)
}
@@ -99,7 +101,7 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) {
if err != nil {
t.Fatalf("work logout --stop-timer error = %v (output: %q)", err, out)
}
- state, err = timrTimer.LoadState()
+ state, err = timesamuraiTimer.LoadState()
if err != nil {
t.Fatalf("LoadState() error = %v", err)
}
@@ -108,6 +110,42 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) {
}
}
+func TestWorkDayOffCommand(t *testing.T) {
+ dbDir := t.TempDir()
+ host := "host-day-off"
+ cfgPath := writeWorkConfig(t, dbDir, host)
+
+ out, err := runRootCommand("--config", cfgPath, "work", "day-off", "--at", "2026-02-17", "--descr", "vacation")
+ if err != nil {
+ t.Fatalf("work day-off error = %v (output: %q)", err, out)
+ }
+ if !strings.Contains(out, "Added day off: 8h on 2026-02-17") {
+ t.Fatalf("unexpected day-off output: %q", out)
+ }
+
+ db, err := worktime.LoadHost(dbDir, host)
+ if err != nil {
+ t.Fatalf("LoadHost() error = %v", err)
+ }
+
+ entries := db.Entries[host]
+ if len(entries) != 1 {
+ t.Fatalf("entries len = %d, want 1", len(entries))
+ }
+
+ entry := entries[0]
+ wantEpoch := time.Date(2026, 2, 17, 0, 0, 0, 0, time.Local).Unix()
+ if entry.Action != "add" || entry.What != "off" {
+ t.Fatalf("unexpected day-off entry: %+v", entry)
+ }
+ if entry.Value != 8*3600 {
+ t.Fatalf("day-off value = %d, want 28800", entry.Value)
+ }
+ if entry.Epoch != wantEpoch {
+ t.Fatalf("day-off epoch = %d, want %d", entry.Epoch, wantEpoch)
+ }
+}
+
func writeWorkConfig(t *testing.T, dbDir, host string) string {
return writeWorkConfigWithAuto(t, dbDir, host, false)
}