diff options
42 files changed, 1854 insertions, 248 deletions
@@ -1,3 +1,3 @@ -/timr +/timesamurai /main -/cmd/timr/timr +/cmd/timesamurai/timesamurai diff --git a/Magefile.go b/Magefile.go index 4b150c0..3b00b47 100644 --- a/Magefile.go +++ b/Magefile.go @@ -9,11 +9,11 @@ import ( var Default = Build func Build() error { - return sh.RunV("go", "build", "-o", "timr", "./cmd/timr") + return sh.RunV("go", "build", "-o", "timesamurai", "./cmd/timesamurai") } func Run() error { - return sh.RunV("go", "run", "./cmd/timr") + return sh.RunV("go", "run", "./cmd/timesamurai") } func Test() error { @@ -25,5 +25,5 @@ func Lint() error { } func Install() error { - return sh.RunV("go", "install", "./cmd/timr") + return sh.RunV("go", "install", "./cmd/timesamurai") } @@ -1,13 +1,13 @@ -# timr +# timesamurai -`timr` is a terminal time-tracking tool that combines: +`timesamurai` is a terminal time-tracking tool that combines: - a stopwatch timer, - worktime-style work log tracking, - weekly reporting, - and a Bubble Tea TUI. -Current version: `v0.4.0`. +Current version: `v0.5.0`. ## Installation @@ -26,49 +26,50 @@ mage build Or build directly: ```bash -go build -o timr ./cmd/timr +go build -o timesamurai ./cmd/timesamurai ``` ## CLI Overview -`timr` now uses Cobra and supports global flags: +`timesamurai` now uses Cobra and supports global flags: ```bash -timr --version -timr --config /path/to/config.json +timesamurai --version +timesamurai --config /path/to/config.json ``` Top-level command groups: -- `timr timer ...` -- `timr work ...` -- `timr tui` +- `timesamurai timer ...` +- `timesamurai work ...` +- `timesamurai tui` ### `timer` Commands ```bash -timr timer start -timr timer stop -timr timer continue -timr timer reset -timr timer status [--raw|--raw-minutes] -timr timer prompt -timr timer track <description> -timr timer live [-f|--font <font>] +timesamurai timer start +timesamurai timer stop +timesamurai timer continue +timesamurai timer reset +timesamurai timer status [--raw|--raw-minutes] +timesamurai timer prompt +timesamurai timer track <description> +timesamurai timer live [-f|--font <font>] ``` ### `work` Commands ```bash -timr work login [-c|--category <cat>] [--at <time>] [-d|--descr <text>] [--start-timer] -timr work logout [-c|--category <cat>] [--at <time>] [-d|--descr <text>] [--stop-timer] -timr work add <duration> [-c|--category <cat>] [--at <time>] [-d|--descr <text>] -timr work sub <duration> [-c|--category <cat>] [--at <time>] [-d|--descr <text>] -timr work use-buffer <duration> [--at <time>] [-d|--descr <text>] -timr work status -timr work report [--verbose] [--no-color] -timr work edit -timr work import <file> +timesamurai work login [-c|--category <cat>] [--at <time>] [-d|--descr <text>] [--start-timer] +timesamurai work logout [-c|--category <cat>] [--at <time>] [-d|--descr <text>] [--stop-timer] +timesamurai work add <duration> [-c|--category <cat>] [--at <time>] [-d|--descr <text>] +timesamurai work sub <duration> [-c|--category <cat>] [--at <time>] [-d|--descr <text>] +timesamurai work day-off [--at <time>] [-d|--descr <text>] +timesamurai work use-buffer <duration> [--at <time>] [-d|--descr <text>] +timesamurai work status +timesamurai work report [--verbose] [--no-color] +timesamurai work edit +timesamurai work import <file> ``` ## TUI @@ -76,20 +77,23 @@ timr work import <file> Launch the TUI: ```bash -timr tui +timesamurai tui +timesamurai tui --disco ``` -The scaffold currently includes: +The TUI includes: - tab-based navigation (`Entries`, `Report`, `Timer`), -- vi-style global keys (`Tab`, `gt`, `gT`, `1/2/3`, `?`, `q`, `ZQ`), -- entries browsing/editing flows, +- TaskSamurai-inspired table styling and status bars, +- vi-style global keys (`Tab`, `gt`, `gT`, `1/2/3`, `?`/`H`, `q`, `ZQ`), +- theme controls (`c` randomize theme, `C` reset, `x` toggle disco mode), +- entries timeline table flows (navigate columns with `h/l` and press `Enter` to edit selected field, including date/time/value/description; `D` opens day-off datepicker), - report browsing, - timer screen with work login/logout toggle (`l`). ## TUI Screenshots -Screenshots section (`v0.4.0` baseline): +Screenshots section (`v0.5.0` baseline): - Entries screen: _to be captured_ - Report screen: _to be captured_ @@ -103,4 +107,4 @@ Install fish prompt helper: ./install-fish-integration.fish ``` -Then add `timr_prompt` to your `fish_prompt` or `fish_right_prompt`. +Then add `timesamurai_prompt` to your `fish_prompt` or `fish_right_prompt`. diff --git a/cmd/timr/main.go b/cmd/timesamurai/main.go index eea7d78..f1a8ab8 100644 --- a/cmd/timr/main.go +++ b/cmd/timesamurai/main.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "codeberg.org/snonux/timr/internal/cli" + "codeberg.org/snonux/timesamurai/internal/cli" ) func main() { @@ -1,4 +1,4 @@ -module codeberg.org/snonux/timr +module codeberg.org/snonux/timesamurai go 1.24.3 diff --git a/install-fish-integration.fish b/install-fish-integration.fish index b90be24..90024d8 100755 --- a/install-fish-integration.fish +++ b/install-fish-integration.fish @@ -2,9 +2,9 @@ set -l fish_config_dir (set -q XDG_CONFIG_HOME; and echo "$XDG_CONFIG_HOME/fish"; or echo "$HOME/.config/fish") set -l fish_conf_d "$fish_config_dir/conf.d" -set -l integration_file "$fish_conf_d/timr.fish" +set -l integration_file "$fish_conf_d/timesamurai.fish" -echo "Installing timr fish shell integration..." +echo "Installing timesamurai fish shell integration..." # Create fish config directory if it doesn't exist if not test -d "$fish_conf_d" @@ -14,12 +14,12 @@ end # Write the integration file echo "\ -function timr_prompt -d \"Display timr timr_status in the prompt\" - if command -v timr >/dev/null - set -l timr_status (timr prompt) - if test -n \"\$timr_status\" - set -l icon (string sub -l 1 -- \"\$timr_status\") - set -l time (string sub -s 2 -- \"\$timr_status\") +function timesamurai_prompt -d \"Display timesamurai timesamurai_status in the prompt\" + if command -v timesamurai >/dev/null + set -l timesamurai_status (timesamurai prompt) + if test -n \"\$timesamurai_status\" + set -l icon (string sub -l 1 -- \"\$timesamurai_status\") + set -l time (string sub -s 2 -- \"\$timesamurai_status\") if test \"\$icon\" = \"▶\" set_color green else @@ -32,30 +32,30 @@ function timr_prompt -d \"Display timr timr_status in the prompt\" end end -complete -c timr -n __fish_use_subcommand -a start -d \"Start the timer\" -complete -c timr -n __fish_use_subcommand -a stop -d \"Stop the timer\" -complete -c timr -n __fish_use_subcommand -a pause -d \"Pause the timer\" -complete -c timr -n __fish_use_subcommand -a continue -d \"Continue the timer\" -complete -c timr -n __fish_use_subcommand -a cont -d \"Continue the timer\" -complete -c timr -n __fish_use_subcommand -a status -d \"Show the timer status\" -complete -c timr -n __fish_use_subcommand -a reset -d \"Reset the timer\" -complete -c timr -n __fish_use_subcommand -a track -d \"Save time with description\" -complete -c timr -n __fish_use_subcommand -a live -d \"Show the live timer\" -complete -c timr -n __fish_use_subcommand -a prompt -d \"Show the prompt status\" +complete -c timesamurai -n __fish_use_subcommand -a start -d \"Start the timer\" +complete -c timesamurai -n __fish_use_subcommand -a stop -d \"Stop the timer\" +complete -c timesamurai -n __fish_use_subcommand -a pause -d \"Pause the timer\" +complete -c timesamurai -n __fish_use_subcommand -a continue -d \"Continue the timer\" +complete -c timesamurai -n __fish_use_subcommand -a cont -d \"Continue the timer\" +complete -c timesamurai -n __fish_use_subcommand -a status -d \"Show the timer status\" +complete -c timesamurai -n __fish_use_subcommand -a reset -d \"Reset the timer\" +complete -c timesamurai -n __fish_use_subcommand -a track -d \"Save time with description\" +complete -c timesamurai -n __fish_use_subcommand -a live -d \"Show the live timer\" +complete -c timesamurai -n __fish_use_subcommand -a prompt -d \"Show the prompt status\" # Font completions for live mode -complete -c timr -n \"__fish_seen_subcommand_from live\" -s f -l font -d \"Font style\" -a \"doom mono12 rebel ansi ansiShadow\" +complete -c timesamurai -n \"__fish_seen_subcommand_from live\" -s f -l font -d \"Font style\" -a \"doom mono12 rebel ansi ansiShadow\" " > "$integration_file" echo "Created: $integration_file" echo "" echo "Installation complete!" echo "" -echo "To display timr in your prompt, add this to your fish_prompt or fish_right_prompt:" +echo "To display timesamurai in your prompt, add this to your fish_prompt or fish_right_prompt:" echo "" echo " function fish_prompt" echo " # ... your existing prompt ..." -echo " printf '%s ' (timr_prompt)" +echo " printf '%s ' (timesamurai_prompt)" echo " end" echo "" echo "Restart your fish shell or run 'source $integration_file' to apply changes." 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) |
