From ae5494d32ea9d486f7cbab6d0edf209c4d85a24c Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 3 Mar 2026 22:51:37 +0200 Subject: Task 352: add work cobra subcommands --- internal/cli/root.go | 1 + internal/cli/work.go | 517 ++++++++++++++++++++++++++++++++++++++++++++++ internal/cli/work_test.go | 100 +++++++++ 3 files changed, 618 insertions(+) create mode 100644 internal/cli/work.go create mode 100644 internal/cli/work_test.go 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 ", + 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 ", + 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 ", + 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 ", + 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 +} -- cgit v1.2.3