diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 23:10:26 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 23:10:26 +0200 |
| commit | e96b0b370bcdd55ad2d5b20187e4bbae78785ff2 (patch) | |
| tree | 2475df7fb9119a3f1db95f54f9f7e718d65324b6 | |
| parent | 28920ad225992386069d8513d0cf097dd50daeef (diff) | |
Task 352: integrate timer and worktime login sync
| -rw-r--r-- | internal/cli/timer.go | 37 | ||||
| -rw-r--r-- | internal/cli/timer_test.go | 40 | ||||
| -rw-r--r-- | internal/cli/work.go | 43 | ||||
| -rw-r--r-- | internal/cli/work_test.go | 41 |
4 files changed, 158 insertions, 3 deletions
diff --git a/internal/cli/timer.go b/internal/cli/timer.go index aafcda0..86c958f 100644 --- a/internal/cli/timer.go +++ b/internal/cli/timer.go @@ -6,10 +6,12 @@ import ( "math/rand/v2" "strconv" "strings" + "time" "codeberg.org/snonux/timr/internal/ascii" "codeberg.org/snonux/timr/internal/live" timrTimer "codeberg.org/snonux/timr/internal/timer" + "codeberg.org/snonux/timr/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) @@ -50,6 +52,9 @@ func newTimerStartCmd() *cobra.Command { if err != nil { return err } + if err := syncWorktimeWithTimer(true); err != nil { + return err + } return printOutput(cmd, output) }, } @@ -64,6 +69,9 @@ func newTimerStopCmd() *cobra.Command { if err != nil { return err } + if err := syncWorktimeWithTimer(false); err != nil { + return err + } return printOutput(cmd, output) }, } @@ -207,3 +215,32 @@ func printOutput(cmd *cobra.Command, output string) error { _, err := fmt.Fprintln(cmd.OutOrStdout(), output) return err } + +func syncWorktimeWithTimer(start bool) error { + cfg := CurrentConfig() + if !cfg.AutoWorktimeLogin { + return nil + } + + ctx, err := resolveWorkContext() + if err != nil { + return err + } + + now := time.Now() + if start { + _, err = worktime.Login(ctx.dbDir, ctx.host, "work", now, "auto timer start") + } else { + _, err = worktime.Logout(ctx.dbDir, ctx.host, "work", now, "auto timer stop") + } + if err == nil { + return nil + } + + // Avoid failing timer commands on no-op state sync mismatches. + if strings.Contains(err.Error(), "already logged in") || strings.Contains(err.Error(), "not logged in") { + return nil + } + + return err +} diff --git a/internal/cli/timer_test.go b/internal/cli/timer_test.go index dead994..25f9618 100644 --- a/internal/cli/timer_test.go +++ b/internal/cli/timer_test.go @@ -7,6 +7,7 @@ import ( "testing" timrTimer "codeberg.org/snonux/timr/internal/timer" + "codeberg.org/snonux/timr/internal/worktime" ) func TestTimerStartAndStopCommands(t *testing.T) { @@ -73,6 +74,45 @@ func TestTimerStatusFlagConflict(t *testing.T) { } } +func TestTimerAutoWorktimeSync(t *testing.T) { + setupTimerState(t) + + dbDir := t.TempDir() + cfgPath := writeWorkConfigWithAuto(t, dbDir, "host-auto", true) + + out, err := runRootCommand("--config", cfgPath, "timer", "start") + if err != nil { + t.Fatalf("timer start error = %v (output: %q)", err, out) + } + out, err = runRootCommand("--config", cfgPath, "timer", "stop") + if err != nil { + t.Fatalf("timer stop error = %v (output: %q)", err, out) + } + + entries, err := worktime.LoadAll(dbDir) + if err != nil { + t.Fatalf("LoadAll() error = %v", err) + } + + var hasLogin bool + var hasLogout bool + for _, entry := range entries { + if entry.What != "work" { + continue + } + if entry.Action == "login" { + hasLogin = true + } + if entry.Action == "logout" { + hasLogout = true + } + } + + if !hasLogin || !hasLogout { + t.Fatalf("auto worktime sync missing login/logout entries: %+v", entries) + } +} + func setupTimerState(t *testing.T) { t.Helper() diff --git a/internal/cli/work.go b/internal/cli/work.go index 4e1ff8a..b454612 100644 --- a/internal/cli/work.go +++ b/internal/cli/work.go @@ -14,6 +14,7 @@ import ( "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" "github.com/spf13/cobra" ) @@ -46,6 +47,7 @@ func newWorkLoginCmd() *cobra.Command { var category string var at string var descr string + var startTimer bool cmd := &cobra.Command{ Use: "login", @@ -66,13 +68,23 @@ func newWorkLoginCmd() *cobra.Command { return err } - return printOutput(cmd, fmt.Sprintf("Logged in: %s at %s", entry.What, entry.Human)) + message := fmt.Sprintf("Logged in: %s at %s", entry.What, entry.Human) + if startTimer { + timerMessage, err := startTimerFromWorkCommand() + if err != nil { + return err + } + message += "\n" + timerMessage + } + + return printOutput(cmd, message) }, } cmd.Flags().StringVarP(&category, "category", "c", "work", "Category to log in") cmd.Flags().StringVar(&at, "at", "", "Timestamp override (unix, ISO, today, yesterday)") cmd.Flags().StringVarP(&descr, "descr", "d", "", "Description") + cmd.Flags().BoolVar(&startTimer, "start-timer", false, "Also start the stopwatch timer") return cmd } @@ -80,6 +92,7 @@ func newWorkLogoutCmd() *cobra.Command { var category string var at string var descr string + var stopTimer bool cmd := &cobra.Command{ Use: "logout", @@ -100,13 +113,23 @@ func newWorkLogoutCmd() *cobra.Command { return err } - return printOutput(cmd, fmt.Sprintf("Logged out: %s at %s", entry.What, entry.Human)) + message := fmt.Sprintf("Logged out: %s at %s", entry.What, entry.Human) + if stopTimer { + timerMessage, err := stopTimerFromWorkCommand() + if err != nil { + return err + } + message += "\n" + timerMessage + } + + return printOutput(cmd, message) }, } cmd.Flags().StringVarP(&category, "category", "c", "work", "Category to log out") cmd.Flags().StringVar(&at, "at", "", "Timestamp override (unix, ISO, today, yesterday)") cmd.Flags().StringVarP(&descr, "descr", "d", "", "Description") + cmd.Flags().BoolVar(&stopTimer, "stop-timer", false, "Also stop the stopwatch timer") return cmd } @@ -515,3 +538,19 @@ func parseImportDate(token string) (time.Time, error) { func workDBPath(dbDir, host string) string { return filepath.Join(dbDir, "db."+host+".json") } + +func startTimerFromWorkCommand() (string, error) { + rawStatus, err := timrTimer.GetRawStatus() + if err != nil { + return "", err + } + status, err := strconv.ParseFloat(rawStatus, 64) + if err != nil { + return "", err + } + return timrTimer.StartTimer(status > 0) +} + +func stopTimerFromWorkCommand() (string, error) { + return timrTimer.StopTimer() +} diff --git a/internal/cli/work_test.go b/internal/cli/work_test.go index 2e44e02..9621bfb 100644 --- a/internal/cli/work_test.go +++ b/internal/cli/work_test.go @@ -4,8 +4,11 @@ import ( "bytes" "os" "path/filepath" + "strconv" "strings" "testing" + + timrTimer "codeberg.org/snonux/timr/internal/timer" ) func TestWorkLoginStatusLogoutFlow(t *testing.T) { @@ -74,12 +77,48 @@ func TestWorkAddSubUseBufferAndReport(t *testing.T) { } } +func TestWorkLoginLogoutWithTimerFlags(t *testing.T) { + setupTimerState(t) + + dbDir := t.TempDir() + cfgPath := writeWorkConfig(t, dbDir, "host-c") + + out, err := runRootCommand("--config", cfgPath, "work", "login", "--at", "2026-01-08T09:00", "--start-timer") + if err != nil { + t.Fatalf("work login --start-timer error = %v (output: %q)", err, out) + } + state, err := timrTimer.LoadState() + if err != nil { + t.Fatalf("LoadState() error = %v", err) + } + if !state.Running { + t.Fatal("timer should be running after --start-timer") + } + + out, err = runRootCommand("--config", cfgPath, "work", "logout", "--at", "2026-01-08T10:00", "--stop-timer") + if err != nil { + t.Fatalf("work logout --stop-timer error = %v (output: %q)", err, out) + } + state, err = timrTimer.LoadState() + if err != nil { + t.Fatalf("LoadState() error = %v", err) + } + if state.Running { + t.Fatal("timer should be stopped after --stop-timer") + } +} + func writeWorkConfig(t *testing.T, dbDir, host string) string { + return writeWorkConfigWithAuto(t, dbDir, host, false) +} + +func writeWorkConfigWithAuto(t *testing.T, dbDir, host string, auto bool) string { t.Helper() content := `{ "worktime_db_dir": "` + dbDir + `", - "hostname": "` + host + `" + "hostname": "` + host + `", + "auto_worktime_login": ` + strconv.FormatBool(auto) + ` } ` path := filepath.Join(t.TempDir(), "config.json") |
