summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 23:10:26 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 23:10:26 +0200
commite96b0b370bcdd55ad2d5b20187e4bbae78785ff2 (patch)
tree2475df7fb9119a3f1db95f54f9f7e718d65324b6
parent28920ad225992386069d8513d0cf097dd50daeef (diff)
Task 352: integrate timer and worktime login sync
-rw-r--r--internal/cli/timer.go37
-rw-r--r--internal/cli/timer_test.go40
-rw-r--r--internal/cli/work.go43
-rw-r--r--internal/cli/work_test.go41
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")