summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 22:51:37 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 22:51:37 +0200
commitae5494d32ea9d486f7cbab6d0edf209c4d85a24c (patch)
treeaa148eb0f1cb902a5c10666629934d3cb509c331
parent212182216ce42e51da21541f39d485ae11fe5c4c (diff)
Task 352: add work cobra subcommands
-rw-r--r--internal/cli/root.go1
-rw-r--r--internal/cli/work.go517
-rw-r--r--internal/cli/work_test.go100
3 files changed, 618 insertions, 0 deletions
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 53b7758..f2512b5 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -50,6 +50,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())
+ cmd.AddCommand(newWorkCmd())
return cmd
}
diff --git a/internal/cli/work.go b/internal/cli/work.go
new file mode 100644
index 0000000..4e1ff8a
--- /dev/null
+++ b/internal/cli/work.go
@@ -0,0 +1,517 @@
+package cli
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "codeberg.org/snonux/timr/internal/duration"
+ "codeberg.org/snonux/timr/internal/timefmt"
+ "codeberg.org/snonux/timr/internal/worktime"
+ "github.com/spf13/cobra"
+)
+
+type workContext struct {
+ dbDir string
+ host string
+}
+
+func newWorkCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "work",
+ Short: "Work-time tracking operations",
+ }
+
+ cmd.AddCommand(newWorkLoginCmd())
+ cmd.AddCommand(newWorkLogoutCmd())
+ cmd.AddCommand(newWorkAddCmd())
+ cmd.AddCommand(newWorkSubCmd())
+ cmd.AddCommand(newWorkUseBufferCmd())
+ cmd.AddCommand(newWorkReportCmd())
+ cmd.AddCommand(newWorkStatusCmd())
+ cmd.AddCommand(newWorkEditCmd())
+ cmd.AddCommand(newWorkImportCmd())
+
+ return cmd
+}
+
+func newWorkLoginCmd() *cobra.Command {
+ var category string
+ var at string
+ var descr string
+
+ cmd := &cobra.Command{
+ Use: "login",
+ Short: "Start work-time tracking",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx, err := resolveWorkContext()
+ if err != nil {
+ return err
+ }
+
+ when, err := parseAtOrNow(at)
+ if err != nil {
+ return err
+ }
+
+ entry, err := worktime.Login(ctx.dbDir, ctx.host, category, when, descr)
+ if err != nil {
+ return err
+ }
+
+ return printOutput(cmd, fmt.Sprintf("Logged in: %s at %s", entry.What, entry.Human))
+ },
+ }
+
+ 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")
+ return cmd
+}
+
+func newWorkLogoutCmd() *cobra.Command {
+ var category string
+ var at string
+ var descr string
+
+ cmd := &cobra.Command{
+ Use: "logout",
+ Short: "Stop work-time tracking",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx, err := resolveWorkContext()
+ if err != nil {
+ return err
+ }
+
+ when, err := parseAtOrNow(at)
+ if err != nil {
+ return err
+ }
+
+ entry, err := worktime.Logout(ctx.dbDir, ctx.host, category, when, descr)
+ if err != nil {
+ return err
+ }
+
+ return printOutput(cmd, fmt.Sprintf("Logged out: %s at %s", entry.What, entry.Human))
+ },
+ }
+
+ 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")
+ return cmd
+}
+
+func newWorkAddCmd() *cobra.Command {
+ var category string
+ var at string
+ var descr string
+
+ cmd := &cobra.Command{
+ Use: "add <duration>",
+ Short: "Add manual work-time duration",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx, err := resolveWorkContext()
+ if err != nil {
+ return err
+ }
+
+ parsedDuration, err := duration.Parse(args[0])
+ if err != nil {
+ return err
+ }
+
+ when, err := parseAtOrNow(at)
+ if err != nil {
+ return err
+ }
+
+ entry, err := worktime.Add(ctx.dbDir, ctx.host, category, parsedDuration, when, descr)
+ if err != nil {
+ return err
+ }
+
+ return printOutput(cmd, fmt.Sprintf("Added: %s (%s)", entry.What, args[0]))
+ },
+ }
+
+ cmd.Flags().StringVarP(&category, "category", "c", "work", "Category")
+ cmd.Flags().StringVar(&at, "at", "", "Timestamp override (unix, ISO, today, yesterday)")
+ cmd.Flags().StringVarP(&descr, "descr", "d", "", "Description")
+ return cmd
+}
+
+func newWorkSubCmd() *cobra.Command {
+ var category string
+ var at string
+ var descr string
+
+ cmd := &cobra.Command{
+ Use: "sub <duration>",
+ Short: "Subtract manual work-time duration",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx, err := resolveWorkContext()
+ if err != nil {
+ return err
+ }
+
+ parsedDuration, err := duration.Parse(args[0])
+ if err != nil {
+ return err
+ }
+
+ when, err := parseAtOrNow(at)
+ if err != nil {
+ return err
+ }
+
+ entry, err := worktime.Sub(ctx.dbDir, ctx.host, category, parsedDuration, when, descr)
+ if err != nil {
+ return err
+ }
+
+ return printOutput(cmd, fmt.Sprintf("Subtracted: %s (%s)", entry.What, args[0]))
+ },
+ }
+
+ cmd.Flags().StringVarP(&category, "category", "c", "work", "Category")
+ cmd.Flags().StringVar(&at, "at", "", "Timestamp override (unix, ISO, today, yesterday)")
+ cmd.Flags().StringVarP(&descr, "descr", "d", "", "Description")
+ return cmd
+}
+
+func newWorkUseBufferCmd() *cobra.Command {
+ var at string
+ var descr string
+
+ cmd := &cobra.Command{
+ Use: "use-buffer <duration>",
+ Short: "Transfer selfdevelopment buffer time into work",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx, err := resolveWorkContext()
+ if err != nil {
+ return err
+ }
+
+ parsedDuration, err := duration.Parse(args[0])
+ if err != nil {
+ return err
+ }
+
+ when, err := parseAtOrNow(at)
+ if err != nil {
+ return err
+ }
+
+ entries, err := worktime.UseBuffer(ctx.dbDir, ctx.host, parsedDuration, when, descr)
+ if err != nil {
+ return err
+ }
+
+ return printOutput(cmd, fmt.Sprintf("Buffer transfer complete (%d entries).", len(entries)))
+ },
+ }
+
+ cmd.Flags().StringVar(&at, "at", "", "Timestamp override (unix, ISO, today, yesterday)")
+ cmd.Flags().StringVarP(&descr, "descr", "d", "", "Description")
+ return cmd
+}
+
+func newWorkReportCmd() *cobra.Command {
+ var verbose bool
+ var noColor bool
+
+ cmd := &cobra.Command{
+ Use: "report",
+ Short: "Print weekly work-time report",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg := CurrentConfig()
+
+ entries, err := worktime.LoadAll(cfg.WorktimeDBDir)
+ if err != nil {
+ return err
+ }
+ if len(entries) == 0 {
+ return printOutput(cmd, "No entries found.")
+ }
+
+ report, err := worktime.BuildReport(entries, cfg)
+ if err != nil {
+ return err
+ }
+
+ rendered := worktime.FormatReport(report, verbose, !noColor)
+ _, err = fmt.Fprint(cmd.OutOrStdout(), rendered)
+ return err
+ },
+ }
+
+ cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show epoch timestamps in day lines")
+ cmd.Flags().BoolVar(&noColor, "no-color", false, "Disable ANSI colors")
+ return cmd
+}
+
+func newWorkStatusCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "Show current login status",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg := CurrentConfig()
+ entries, err := worktime.LoadAll(cfg.WorktimeDBDir)
+ if err != nil {
+ return err
+ }
+
+ active := activeCategories(entries)
+ if len(active) == 0 {
+ return printOutput(cmd, "Not logged in.")
+ }
+
+ return printOutput(cmd, "Logged in: "+strings.Join(active, ", "))
+ },
+ }
+}
+
+func newWorkEditCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "edit",
+ Short: "Open host DB JSON in $EDITOR",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx, err := resolveWorkContext()
+ if err != nil {
+ return err
+ }
+
+ db, err := worktime.LoadHost(ctx.dbDir, ctx.host)
+ if err != nil {
+ return err
+ }
+ if err := worktime.SaveHost(ctx.dbDir, ctx.host, db); err != nil {
+ return err
+ }
+
+ editor := strings.TrimSpace(os.Getenv("EDITOR"))
+ if editor == "" {
+ editor = "vi"
+ }
+
+ editorParts := strings.Fields(editor)
+ editorCmd := exec.Command(editorParts[0], append(editorParts[1:], workDBPath(ctx.dbDir, ctx.host))...)
+ editorCmd.Stdin = os.Stdin
+ editorCmd.Stdout = os.Stdout
+ editorCmd.Stderr = os.Stderr
+ return editorCmd.Run()
+ },
+ }
+}
+
+func newWorkImportCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "import <file>",
+ Short: "Import report-format text file",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx, err := resolveWorkContext()
+ if err != nil {
+ return err
+ }
+
+ imported, err := importReportFile(ctx, args[0])
+ if err != nil {
+ return err
+ }
+
+ return printOutput(cmd, fmt.Sprintf("Imported %d entries.", imported))
+ },
+ }
+}
+
+func resolveWorkContext() (workContext, error) {
+ cfg := CurrentConfig()
+ if strings.TrimSpace(cfg.WorktimeDBDir) == "" {
+ return workContext{}, errors.New("worktime_db_dir is empty in config")
+ }
+
+ host, err := cfg.EffectiveHostname()
+ if err != nil {
+ return workContext{}, err
+ }
+
+ return workContext{
+ dbDir: cfg.WorktimeDBDir,
+ host: host,
+ }, nil
+}
+
+func parseAtOrNow(value string) (time.Time, error) {
+ if strings.TrimSpace(value) == "" {
+ return time.Now(), nil
+ }
+ return timefmt.Parse(value)
+}
+
+func activeCategories(entries []worktime.Entry) []string {
+ status := map[string]bool{}
+
+ for _, entry := range entries {
+ category := strings.TrimSpace(entry.What)
+ if category == "" {
+ category = "work"
+ }
+
+ switch strings.ToLower(strings.TrimSpace(entry.Action)) {
+ case "login":
+ status[category] = true
+ case "logout":
+ status[category] = false
+ }
+ }
+
+ active := make([]string, 0)
+ for category, isActive := range status {
+ if isActive {
+ active = append(active, category)
+ }
+ }
+ sort.Strings(active)
+ return active
+}
+
+func importReportFile(ctx workContext, path string) (int, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return 0, err
+ }
+ defer file.Close()
+
+ imported := 0
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.Contains(line, "lunch:") {
+ continue
+ }
+
+ when, workHours, lunchHours, offHours, err := parseImportLine(line)
+ if err != nil {
+ return imported, err
+ }
+
+ workSeconds := int64(workHours * float64(time.Hour/time.Second))
+ lunchSeconds := int64(lunchHours * float64(time.Hour/time.Second))
+ offSeconds := int64(offHours * float64(time.Hour/time.Second))
+
+ if lunchSeconds > 0 {
+ workSeconds += lunchSeconds
+ }
+
+ if workSeconds > 0 {
+ if _, err := worktime.Add(ctx.dbDir, ctx.host, "work", time.Duration(workSeconds)*time.Second, when, ""); err != nil {
+ return imported, err
+ }
+ imported++
+ }
+ if lunchSeconds > 0 {
+ if _, err := worktime.Add(ctx.dbDir, ctx.host, "lunch", time.Duration(lunchSeconds)*time.Second, when, ""); err != nil {
+ return imported, err
+ }
+ imported++
+ }
+ if offSeconds > 0 {
+ if _, err := worktime.Add(ctx.dbDir, ctx.host, "off", time.Duration(offSeconds)*time.Second, when, ""); err != nil {
+ return imported, err
+ }
+ imported++
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ return imported, err
+ }
+
+ return imported, nil
+}
+
+func parseImportLine(line string) (time.Time, float64, float64, float64, error) {
+ fields := strings.Fields(line)
+ if len(fields) < 7 {
+ return time.Time{}, 0, 0, 0, fmt.Errorf("unsupported import line: %q", line)
+ }
+
+ dateToken := strings.TrimSuffix(fields[1], ":")
+ workToken := fields[2]
+ lunchToken := fields[4]
+ offToken := fields[6]
+
+ when, err := parseImportDate(dateToken)
+ if err != nil {
+ return time.Time{}, 0, 0, 0, err
+ }
+
+ workHours, err := parseHourToken(workToken)
+ if err != nil {
+ return time.Time{}, 0, 0, 0, err
+ }
+ lunchHours, err := parseHourToken(lunchToken)
+ if err != nil {
+ return time.Time{}, 0, 0, 0, err
+ }
+ offHours, err := parseHourToken(offToken)
+ if err != nil {
+ return time.Time{}, 0, 0, 0, err
+ }
+
+ return when, workHours, lunchHours, offHours, nil
+}
+
+func parseHourToken(token string) (float64, error) {
+ clean := strings.TrimSpace(token)
+ if idx := strings.Index(clean, ":"); idx >= 0 {
+ clean = clean[idx+1:]
+ }
+ clean = strings.TrimSuffix(clean, "h")
+
+ value, err := strconv.ParseFloat(clean, 64)
+ if err != nil {
+ return 0, fmt.Errorf("parse hour token %q: %w", token, err)
+ }
+ return value, nil
+}
+
+func parseImportDate(token string) (time.Time, error) {
+ trimmed := strings.TrimSpace(token)
+ if trimmed == "" {
+ return time.Time{}, errors.New("import date is empty")
+ }
+
+ if parsed, err := timefmt.Parse(trimmed); err == nil {
+ return parsed, nil
+ }
+
+ layouts := []string{
+ "02.01.2006",
+ "20060102",
+ }
+ for _, layout := range layouts {
+ if parsed, err := time.ParseInLocation(layout, trimmed, time.Local); err == nil {
+ return parsed, nil
+ }
+ }
+
+ return time.Time{}, fmt.Errorf("unsupported import date %q", token)
+}
+
+func workDBPath(dbDir, host string) string {
+ return filepath.Join(dbDir, "db."+host+".json")
+}
diff --git a/internal/cli/work_test.go b/internal/cli/work_test.go
new file mode 100644
index 0000000..2e44e02
--- /dev/null
+++ b/internal/cli/work_test.go
@@ -0,0 +1,100 @@
+package cli
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestWorkLoginStatusLogoutFlow(t *testing.T) {
+ dbDir := t.TempDir()
+ cfgPath := writeWorkConfig(t, dbDir, "host-a")
+
+ out, err := runRootCommand("--config", cfgPath, "work", "login", "--at", "2026-01-05T09:00")
+ if err != nil {
+ t.Fatalf("work login error = %v (output: %q)", err, out)
+ }
+ if !strings.Contains(out, "Logged in:") {
+ t.Fatalf("unexpected login output: %q", out)
+ }
+
+ out, err = runRootCommand("--config", cfgPath, "work", "status")
+ if err != nil {
+ t.Fatalf("work status error = %v (output: %q)", err, out)
+ }
+ if !strings.Contains(out, "Logged in: work") {
+ t.Fatalf("unexpected status output after login: %q", out)
+ }
+
+ out, err = runRootCommand("--config", cfgPath, "work", "logout", "--at", "2026-01-05T18:00")
+ if err != nil {
+ t.Fatalf("work logout error = %v (output: %q)", err, out)
+ }
+ if !strings.Contains(out, "Logged out:") {
+ t.Fatalf("unexpected logout output: %q", out)
+ }
+
+ out, err = runRootCommand("--config", cfgPath, "work", "status")
+ if err != nil {
+ t.Fatalf("work status error = %v (output: %q)", err, out)
+ }
+ if !strings.Contains(out, "Not logged in.") {
+ t.Fatalf("unexpected status output after logout: %q", out)
+ }
+}
+
+func TestWorkAddSubUseBufferAndReport(t *testing.T) {
+ dbDir := t.TempDir()
+ cfgPath := writeWorkConfig(t, dbDir, "host-b")
+
+ out, err := runRootCommand("--config", cfgPath, "work", "add", "1h", "--at", "2026-01-06T10:00")
+ if err != nil {
+ t.Fatalf("work add error = %v (output: %q)", err, out)
+ }
+ out, err = runRootCommand("--config", cfgPath, "work", "sub", "30m", "--at", "2026-01-06T11:00")
+ if err != nil {
+ t.Fatalf("work sub error = %v (output: %q)", err, out)
+ }
+ out, err = runRootCommand("--config", cfgPath, "work", "use-buffer", "15m", "--at", "2026-01-06T12:00")
+ if err != nil {
+ t.Fatalf("work use-buffer error = %v (output: %q)", err, out)
+ }
+
+ out, err = runRootCommand("--config", cfgPath, "work", "report", "--no-color")
+ if err != nil {
+ t.Fatalf("work report error = %v (output: %q)", err, out)
+ }
+ if !strings.Contains(out, "work:") {
+ t.Fatalf("work report missing work field: %q", out)
+ }
+ if !strings.Contains(out, "buffer:") {
+ t.Fatalf("work report missing buffer field: %q", out)
+ }
+}
+
+func writeWorkConfig(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
+}
+
+func runRootCommand(args ...string) (string, error) {
+ var out bytes.Buffer
+ cmd := NewRootCmd()
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs(args)
+ err := cmd.Execute()
+ return out.String(), err
+}