From 36571b49a10351f6a565a47f80340c634a15286e Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 14:20:43 +0100 Subject: [PATCH 01/12] Refactor clock commands into a dedicated package and simplify `rootCmd` initialization. --- cmd/clock/clock.go | 19 +++++ cmd/clock/in.go | 28 ++++++++ cmd/clock/out.go | 28 ++++++++ cmd/clock/status.go | 56 +++++++++++++++ cmd/entries/add.go | 1 + cmd/entries/delete.go | 1 + cmd/entries/entries.go | 1 + cmd/odoo-work-cli/main.go | 147 ++++++++------------------------------ 8 files changed, 162 insertions(+), 119 deletions(-) create mode 100644 cmd/clock/clock.go create mode 100644 cmd/clock/in.go create mode 100644 cmd/clock/out.go create mode 100644 cmd/clock/status.go create mode 100644 cmd/entries/add.go create mode 100644 cmd/entries/delete.go create mode 100644 cmd/entries/entries.go diff --git a/cmd/clock/clock.go b/cmd/clock/clock.go new file mode 100644 index 0000000..65ee50d --- /dev/null +++ b/cmd/clock/clock.go @@ -0,0 +1,19 @@ +package clock + +import ( + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/spf13/cobra" +) + +func ClockCMD(client *odoo.XMLRPCClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "clock", + Short: "Clock in/out and attendance status", + } + + cmd.AddCommand(inCMD(client)) + cmd.AddCommand(outCMD(client)) + cmd.AddCommand(statusCMD(client)) + + return cmd +} diff --git a/cmd/clock/in.go b/cmd/clock/in.go new file mode 100644 index 0000000..c2a6559 --- /dev/null +++ b/cmd/clock/in.go @@ -0,0 +1,28 @@ +package clock + +import ( + "fmt" + "time" + + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/spf13/cobra" +) + +func inCMD(client *odoo.XMLRPCClient) *cobra.Command { + InCmd := &cobra.Command{ + Use: "in", + Short: "Clock in (start attendance)", + RunE: func(cmd *cobra.Command, args []string) error { + + _, err := client.ClockIn() + if err != nil { + return err + } + + fmt.Printf("Clocked in at %s\n", time.Now().Format("15:04")) + return nil + }, + } + + return InCmd +} diff --git a/cmd/clock/out.go b/cmd/clock/out.go new file mode 100644 index 0000000..69ba8de --- /dev/null +++ b/cmd/clock/out.go @@ -0,0 +1,28 @@ +package clock + +import ( + "fmt" + "time" + + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/tui" + "github.com/spf13/cobra" +) + +func outCMD(client *odoo.XMLRPCClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "out", + Short: "Clock out (end attendance)", + RunE: func(cmd *cobra.Command, args []string) error { + rec, err := client.ClockOut() + if err != nil { + return err + } + + fmt.Printf("Clocked out at %s\n", time.Now().Format("15:04")) + fmt.Printf("Duration: %s (%.2fh)\n", tui.FormatHours(rec.WorkedHours), rec.WorkedHours) + return nil + }, + } + return cmd +} diff --git a/cmd/clock/status.go b/cmd/clock/status.go new file mode 100644 index 0000000..493fdae --- /dev/null +++ b/cmd/clock/status.go @@ -0,0 +1,56 @@ +package clock + +import ( + "fmt" + "time" + + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/tui" + "github.com/spf13/cobra" +) + +func statusCMD(client *odoo.XMLRPCClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show current attendance status", + RunE: func(cmd *cobra.Command, args []string) error { + status, err := client.AttendanceStatus() + if err != nil { + return err + } + + if status.ClockedIn && status.CheckIn != nil { + elapsed := time.Since(*status.CheckIn).Hours() + fmt.Printf("Status: Clocked in since %s (%s elapsed)\n", + status.CheckIn.Local().Format("15:04"), + tui.FormatHours(elapsed)) + } else { + fmt.Println("Status: Not clocked in") + } + + if len(status.Periods) > 0 { + fmt.Print("\nToday's attendance:\n\n") + fmt.Printf("%-3s %-10s %-10s %s\n", "#", "Check In", "Check Out", "Duration") + fmt.Printf("%-3s %-10s %-10s %s\n", "---", "----------", "----------", "--------") + for i, p := range status.Periods { + checkIn := p.CheckIn.Local().Format("15:04") + var checkOut, duration string + if p.CheckOut != nil { + checkOut = p.CheckOut.Local().Format("15:04") + duration = tui.FormatHours(p.WorkedHours) + } else { + checkOut = "--:--" + elapsed := time.Since(p.CheckIn).Hours() + duration = tui.FormatHours(elapsed) + " (running)" + } + fmt.Printf("%-3d %-10s %-10s %s\n", i+1, checkIn, checkOut, duration) + } + fmt.Printf("\nTotal: %s\n", tui.FormatHours(status.TotalHours)) + } else { + fmt.Println("\nNo attendance records today.") + } + return nil + }, + } + return cmd +} diff --git a/cmd/entries/add.go b/cmd/entries/add.go new file mode 100644 index 0000000..6aa3be7 --- /dev/null +++ b/cmd/entries/add.go @@ -0,0 +1 @@ +package entries diff --git a/cmd/entries/delete.go b/cmd/entries/delete.go new file mode 100644 index 0000000..6aa3be7 --- /dev/null +++ b/cmd/entries/delete.go @@ -0,0 +1 @@ +package entries diff --git a/cmd/entries/entries.go b/cmd/entries/entries.go new file mode 100644 index 0000000..6aa3be7 --- /dev/null +++ b/cmd/entries/entries.go @@ -0,0 +1 @@ +package entries diff --git a/cmd/odoo-work-cli/main.go b/cmd/odoo-work-cli/main.go index bc017f5..5f78ddd 100644 --- a/cmd/odoo-work-cli/main.go +++ b/cmd/odoo-work-cli/main.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/BurntSushi/toml" + "github.com/seletz/odoo-work-cli/cmd/clock" "github.com/seletz/odoo-work-cli/internal/config" "github.com/seletz/odoo-work-cli/internal/odoo" "github.com/seletz/odoo-work-cli/internal/tui" @@ -20,18 +21,30 @@ import ( var cfgFile string func main() { - if err := rootCmd.Execute(); err != nil { + + cfg, err := loadConfig() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + client, err := newClient(cfg) + if err != nil { + fmt.Println(err) os.Exit(1) } -} -var rootCmd = &cobra.Command{ - Use: "odoo-work-cli", - Short: "CLI for managing Odoo 17 timesheets", - Version: version.Version, -} + rootCmd := &cobra.Command{ + Use: "odoo-work-cli", + Short: "CLI for managing Odoo 17 timesheets", + Version: version.Version, + + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if client != nil { + client.Close() + } + }, + } -func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path (skip discovery)") rootCmd.AddCommand(projectsCmd) @@ -42,11 +55,14 @@ func init() { rootCmd.AddCommand(configCmd) rootCmd.AddCommand(tuiCmd) rootCmd.AddCommand(entriesCmd) - rootCmd.AddCommand(clockCmd) + rootCmd.AddCommand(clock.ClockCMD(client)) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} - clockCmd.AddCommand(clockInCmd) - clockCmd.AddCommand(clockOutCmd) - clockCmd.AddCommand(clockStatusCmd) +func init() { timesheetsCmd.Flags().StringVar(&tsWeek, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") entriesCmd.Flags().StringVar(&entriesWeek, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") @@ -595,113 +611,6 @@ var tuiCmd = &cobra.Command{ }, } -var clockCmd = &cobra.Command{ - Use: "clock", - Short: "Clock in/out and attendance status", -} - -var clockInCmd = &cobra.Command{ - Use: "in", - Short: "Clock in (start attendance)", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - _, err = client.ClockIn() - if err != nil { - return err - } - - fmt.Printf("Clocked in at %s\n", time.Now().Format("15:04")) - return nil - }, -} - -var clockOutCmd = &cobra.Command{ - Use: "out", - Short: "Clock out (end attendance)", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - rec, err := client.ClockOut() - if err != nil { - return err - } - - fmt.Printf("Clocked out at %s\n", time.Now().Format("15:04")) - fmt.Printf("Duration: %s (%.2fh)\n", tui.FormatHours(rec.WorkedHours), rec.WorkedHours) - return nil - }, -} - -var clockStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show current attendance status", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - status, err := client.AttendanceStatus() - if err != nil { - return err - } - - if status.ClockedIn && status.CheckIn != nil { - elapsed := time.Since(*status.CheckIn).Hours() - fmt.Printf("Status: Clocked in since %s (%s elapsed)\n", - status.CheckIn.Local().Format("15:04"), - tui.FormatHours(elapsed)) - } else { - fmt.Println("Status: Not clocked in") - } - - if len(status.Periods) > 0 { - fmt.Print("\nToday's attendance:\n\n") - fmt.Printf("%-3s %-10s %-10s %s\n", "#", "Check In", "Check Out", "Duration") - fmt.Printf("%-3s %-10s %-10s %s\n", "---", "----------", "----------", "--------") - for i, p := range status.Periods { - checkIn := p.CheckIn.Local().Format("15:04") - var checkOut, duration string - if p.CheckOut != nil { - checkOut = p.CheckOut.Local().Format("15:04") - duration = tui.FormatHours(p.WorkedHours) - } else { - checkOut = "--:--" - elapsed := time.Since(p.CheckIn).Hours() - duration = tui.FormatHours(elapsed) + " (running)" - } - fmt.Printf("%-3d %-10s %-10s %s\n", i+1, checkIn, checkOut, duration) - } - fmt.Printf("\nTotal: %s\n", tui.FormatHours(status.TotalHours)) - } else { - fmt.Println("\nNo attendance records today.") - } - return nil - }, -} - var configMerged bool var configCmd = &cobra.Command{ From b2af33d76abfb6b2417fc74700bd59b111775e16 Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 15:20:08 +0100 Subject: [PATCH 02/12] feature Refactor entries commands into a dedicated package, simplify `rootCmd` initialization, and modularize parsing and filtering logic. --- cmd/clock/clock.go | 2 +- cmd/entries/add.go | 43 +++++ cmd/entries/delete.go | 29 ++++ cmd/entries/entries.go | 165 +++++++++++++++++++ cmd/entries/update.go | 43 +++++ cmd/odoo-work-cli/main.go | 325 +------------------------------------ internal/filter/entries.go | 32 ++++ internal/parsing/times.go | 29 ++++ 8 files changed, 349 insertions(+), 319 deletions(-) create mode 100644 cmd/entries/update.go create mode 100644 internal/filter/entries.go create mode 100644 internal/parsing/times.go diff --git a/cmd/clock/clock.go b/cmd/clock/clock.go index 65ee50d..63b8e3a 100644 --- a/cmd/clock/clock.go +++ b/cmd/clock/clock.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" ) -func ClockCMD(client *odoo.XMLRPCClient) *cobra.Command { +func Cmd(client *odoo.XMLRPCClient) *cobra.Command { cmd := &cobra.Command{ Use: "clock", Short: "Clock in/out and attendance status", diff --git a/cmd/entries/add.go b/cmd/entries/add.go index 6aa3be7..7f190d0 100644 --- a/cmd/entries/add.go +++ b/cmd/entries/add.go @@ -1 +1,44 @@ package entries + +import ( + "fmt" + + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/spf13/cobra" +) + +func addCmd(client *odoo.XMLRPCClient) *cobra.Command { + ops := &subOps{} + cmd := &cobra.Command{ + Use: "add", + Short: "Create a new timesheet entry", + RunE: func(cmd *cobra.Command, args []string) error { + params, err := buildTimesheetWriteParams(ops.projectID, + ops.taskID, + ops.date, + ops.description, + ops.hours) + if err != nil { + return err + } + + id, err := client.CreateTimesheet(params) + if err != nil { + return err + } + + fmt.Printf("Created entry %d\n", id) + return nil + }, + } + + cmd.Flags().Int64Var(&ops.projectID, "project-id", 0, "Odoo project ID (required)") + cmd.Flags().Int64Var(&ops.taskID, "task-id", 0, "Odoo task ID (optional)") + cmd.Flags().StringVar(&ops.date, "date", "", "entry date YYYY-MM-DD (defaults to today)") + cmd.Flags().StringVar(&ops.hours, "hours", "", "hours worked (e.g. 2.5 or 2:30)") + cmd.Flags().StringVar(&ops.description, "description", "", "work description (required)") + _ = cmd.MarkFlagRequired("hours") + _ = cmd.MarkFlagRequired("description") + + return cmd +} diff --git a/cmd/entries/delete.go b/cmd/entries/delete.go index 6aa3be7..007ec53 100644 --- a/cmd/entries/delete.go +++ b/cmd/entries/delete.go @@ -1 +1,30 @@ package entries + +import ( + "fmt" + + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/spf13/cobra" +) + +func deleteCmd(client *odoo.XMLRPCClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ID", + Short: "Delete a timesheet entry", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseEntryID(args[0]) + if err != nil { + return err + } + + if err := client.DeleteTimesheet(id); err != nil { + return err + } + + fmt.Printf("Deleted entry %d\n", id) + return nil + }, + } + return cmd +} diff --git a/cmd/entries/entries.go b/cmd/entries/entries.go index 6aa3be7..88c37c3 100644 --- a/cmd/entries/entries.go +++ b/cmd/entries/entries.go @@ -1 +1,166 @@ package entries + +import ( + "fmt" + "strconv" + "time" + + "github.com/seletz/odoo-work-cli/internal/filter" + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/parsing" + "github.com/seletz/odoo-work-cli/internal/tui" + "github.com/spf13/cobra" +) + +type entrieOps struct { + week string + date string + project string + task string + status string +} + +type subOps struct { + projectID int64 + taskID int64 + date string + hours string + description string +} + +func CMD(client *odoo.XMLRPCClient) *cobra.Command { + ops := &entrieOps{} + + cmd := &cobra.Command{ + Use: "entries", + Short: "List individual timesheet entries with full detail", + RunE: func(cmd *cobra.Command, args []string) error { + var dateFrom, dateTo string + var err error + if ops.date != "" { + dateFrom, dateTo, err = parsing.ParseDateRange(ops.date) + } else { + dateFrom, dateTo, err = parsing.WeekDateRange(ops.week) + } + if err != nil { + return err + } + + entries, err := client.ListTimesheets(dateFrom, dateTo) + if err != nil { + return err + } + + entries = filter.Entries(entries, ops.project, ops.task, ops.status) + + if ops.date != "" { + fmt.Printf("Date: %s\n\n", dateFrom) + } else { + fmt.Printf("Week: %s to %s\n\n", dateFrom, dateTo) + } + + fmt.Printf("%-8s %-12s %-8s %-25s %-8s %-25s %-6s %-10s %s\n", + "ID", "Date", "ProjID", "Project", "TaskID", "Task", "Hours", "Status", "Description") + fmt.Printf("%-8s %-12s %-8s %-25s %-8s %-25s %-6s %-10s %s\n", + "--------", "------------", "--------", "-------------------------", "--------", "-------------------------", "------", "----------", "------------------------------") + + var total float64 + for _, e := range entries { + fmt.Printf("%-8d %-12s %-8d %-25s %-8d %-25s %-6s %-10s %s\n", + e.ID, e.Date, e.ProjectID, e.Project, e.TaskID, e.Task, tui.FormatHours(e.Hours), e.ValidatedStatus, e.Name) + total += e.Hours + } + + fmt.Printf("\nTotal: %s (%d entries)\n", tui.FormatHours(total), len(entries)) + return nil + }, + } + + // Set Flags + cmd.Flags().StringVar(&ops.week, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") + cmd.Flags().StringVar(&ops.date, "date", "", "specific date (YYYY-MM-DD), overrides --week") + cmd.Flags().StringVar(&ops.project, "project", "", "filter by project name (substring, case-insensitive)") + cmd.Flags().StringVar(&ops.task, "task", "", "filter by task name (substring, case-insensitive)") + cmd.Flags().StringVar(&ops.status, "status", "", "filter by validation status (e.g. draft, validated)") + + // Set Subcommands + cmd.AddCommand(addCmd(client)) + cmd.AddCommand(updateCmd(client)) + cmd.AddCommand(deleteCmd(client)) + + return cmd +} + +// buildTimesheetWriteParams constructs and validates TimesheetWriteParams from CLI flag values. +// An empty date defaults to today. Hours accepts both decimal ("2.5") and H:MM ("2:30") formats. +func buildTimesheetWriteParams(projectID, taskID int64, date, description, hoursStr string) (odoo.TimesheetWriteParams, error) { + if date == "" { + date = time.Now().Format("2006-01-02") + } else { + if _, err := time.Parse("2006-01-02", date); err != nil { + return odoo.TimesheetWriteParams{}, fmt.Errorf("invalid date %q: expected YYYY-MM-DD", date) + } + } + hours, err := tui.ParseHours(hoursStr) + if err != nil { + return odoo.TimesheetWriteParams{}, err + } + p := odoo.TimesheetWriteParams{ + ProjectID: projectID, + TaskID: taskID, + Date: date, + Name: description, + Hours: hours, + } + if err := odoo.ValidateTimesheetParams(p); err != nil { + return odoo.TimesheetWriteParams{}, err + } + return p, nil +} + +// buildUpdateFields builds a partial Odoo field map from the flags that were +// explicitly set on the command. Returns an error if a set flag has an invalid value. +func buildUpdateFields(cmd *cobra.Command, ops *subOps) (map[string]interface{}, error) { + fields := make(map[string]interface{}) + if cmd.Flags().Changed("project-id") { + fields["project_id"] = ops.projectID + } + if cmd.Flags().Changed("task-id") { + fields["task_id"] = ops.taskID + } + if cmd.Flags().Changed("date") { + if _, err := time.Parse("2006-01-02", ops.date); err != nil { + return nil, fmt.Errorf("invalid date %q: expected YYYY-MM-DD", ops.date) + } + fields["date"] = ops.date + } + if cmd.Flags().Changed("hours") { + h, err := tui.ParseHours(ops.hours) + if err != nil { + return nil, err + } + fields["unit_amount"] = h + } + if cmd.Flags().Changed("description") { + if ops.description == "" { + return nil, fmt.Errorf("description must not be empty") + } + fields["name"] = ops.description + } + if len(fields) == 0 { + return nil, fmt.Errorf("at least one flag is required (--project-id, --task-id, --date, --hours, --description)") + } + return fields, nil +} + +// parseEntryID parses and validates a timesheet entry ID from a string. +func parseEntryID(s string) (int64, error) { + id, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid entry ID %q: must be a positive integer", s) + } + if id <= 0 { + return 0, fmt.Errorf("invalid entry ID %q: must be a positive integer", s) + } + return id, nil +} diff --git a/cmd/entries/update.go b/cmd/entries/update.go new file mode 100644 index 0000000..1dd5311 --- /dev/null +++ b/cmd/entries/update.go @@ -0,0 +1,43 @@ +package entries + +import ( + "fmt" + + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/spf13/cobra" +) + +func updateCmd(client *odoo.XMLRPCClient) *cobra.Command { + ops := &subOps{} + cmd := &cobra.Command{ + Use: "update ID", + Short: "Update an existing timesheet entry", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseEntryID(args[0]) + if err != nil { + return err + } + + fields, err := buildUpdateFields(cmd, ops) + if err != nil { + return err + } + + if err := client.UpdateTimesheet(id, fields); err != nil { + return err + } + + fmt.Printf("Updated entry %d\n", id) + return nil + }, + } + + cmd.Flags().Int64Var(&ops.projectID, "project-id", 0, "Odoo project ID") + cmd.Flags().Int64Var(&ops.taskID, "task-id", 0, "Odoo task ID") + cmd.Flags().StringVar(&ops.date, "date", "", "entry date YYYY-MM-DD") + cmd.Flags().StringVar(&ops.hours, "hours", "", "hours worked (e.g. 2.5 or 2:30)") + cmd.Flags().StringVar(&ops.description, "description", "", "work description") + + return cmd +} diff --git a/cmd/odoo-work-cli/main.go b/cmd/odoo-work-cli/main.go index 5f78ddd..d8e61e4 100644 --- a/cmd/odoo-work-cli/main.go +++ b/cmd/odoo-work-cli/main.go @@ -6,13 +6,14 @@ import ( "os" "strconv" "strings" - "time" tea "charm.land/bubbletea/v2" "github.com/BurntSushi/toml" "github.com/seletz/odoo-work-cli/cmd/clock" + "github.com/seletz/odoo-work-cli/cmd/entries" "github.com/seletz/odoo-work-cli/internal/config" "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/parsing" "github.com/seletz/odoo-work-cli/internal/tui" "github.com/seletz/odoo-work-cli/internal/version" "github.com/spf13/cobra" @@ -54,10 +55,10 @@ func main() { rootCmd.AddCommand(whoamiCmd) rootCmd.AddCommand(configCmd) rootCmd.AddCommand(tuiCmd) - rootCmd.AddCommand(entriesCmd) - rootCmd.AddCommand(clock.ClockCMD(client)) + rootCmd.AddCommand(entries.CMD(client)) + rootCmd.AddCommand(clock.Cmd(client)) - if err := rootCmd.Execute(); err != nil { + if err = rootCmd.Execute(); err != nil { os.Exit(1) } } @@ -65,32 +66,10 @@ func main() { func init() { timesheetsCmd.Flags().StringVar(&tsWeek, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") - entriesCmd.Flags().StringVar(&entriesWeek, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") - entriesCmd.Flags().StringVar(&entriesDate, "date", "", "specific date (YYYY-MM-DD), overrides --week") - entriesCmd.Flags().StringVar(&entriesProject, "project", "", "filter by project name (substring, case-insensitive)") - entriesCmd.Flags().StringVar(&entriesTask, "task", "", "filter by task name (substring, case-insensitive)") - entriesCmd.Flags().StringVar(&entriesStatus, "status", "", "filter by validation status (e.g. draft, validated)") + tuiCmd.Flags().StringVar(&tuiWeek, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") configCmd.Flags().BoolVar(&configMerged, "merged", false, "print merged TOML config (password redacted)") configCmd.AddCommand(configInstallCmd) - - entriesCmd.AddCommand(entriesAddCmd) - entriesAddCmd.Flags().Int64Var(&addProjectID, "project-id", 0, "Odoo project ID (required)") - entriesAddCmd.Flags().Int64Var(&addTaskID, "task-id", 0, "Odoo task ID (optional)") - entriesAddCmd.Flags().StringVar(&addDate, "date", "", "entry date YYYY-MM-DD (defaults to today)") - entriesAddCmd.Flags().StringVar(&addHours, "hours", "", "hours worked (e.g. 2.5 or 2:30)") - entriesAddCmd.Flags().StringVar(&addDescription, "description", "", "work description (required)") - _ = entriesAddCmd.MarkFlagRequired("hours") - _ = entriesAddCmd.MarkFlagRequired("description") - - entriesCmd.AddCommand(entriesUpdateCmd) - entriesUpdateCmd.Flags().Int64Var(&updateProjectID, "project-id", 0, "Odoo project ID") - entriesUpdateCmd.Flags().Int64Var(&updateTaskID, "task-id", 0, "Odoo task ID") - entriesUpdateCmd.Flags().StringVar(&updateDate, "date", "", "entry date YYYY-MM-DD") - entriesUpdateCmd.Flags().StringVar(&updateHours, "hours", "", "hours worked (e.g. 2.5 or 2:30)") - entriesUpdateCmd.Flags().StringVar(&updateDescription, "description", "", "work description") - - entriesCmd.AddCommand(entriesDeleteCmd) } // loadConfig loads and merges config using file discovery and env vars. @@ -200,17 +179,6 @@ var tasksCmd = &cobra.Command{ }, } -// weekDateRange returns the Monday and Sunday of the ISO week specified -// as "2006-W02" format, or the current week if empty. -func weekDateRange(week string) (string, string, error) { - monday, err := tui.ParseWeekMonday(week) - if err != nil { - return "", "", err - } - sunday := monday.AddDate(0, 0, 6) - return monday.Format("2006-01-02"), sunday.Format("2006-01-02"), nil -} - var tsWeek string var timesheetsCmd = &cobra.Command{ @@ -227,7 +195,7 @@ var timesheetsCmd = &cobra.Command{ } defer client.Close() - dateFrom, dateTo, err := weekDateRange(tsWeek) + dateFrom, dateTo, err := parsing.WeekDateRange(tsWeek) if err != nil { return err } @@ -250,285 +218,6 @@ var timesheetsCmd = &cobra.Command{ }, } -// parseDateRange returns a single-day date range for the given YYYY-MM-DD string. -func parseDateRange(date string) (string, string, error) { - d, err := time.Parse("2006-01-02", date) - if err != nil { - return "", "", fmt.Errorf("invalid date %q: expected YYYY-MM-DD", date) - } - s := d.Format("2006-01-02") - return s, s, nil -} - -// filterEntries returns entries matching the project, task, and status filters. -// Project and task use case-insensitive substring match. Status uses exact match. -// Empty filter matches all. -func filterEntries(entries []odoo.TimesheetEntry, project, task, status string) []odoo.TimesheetEntry { - if project == "" && task == "" && status == "" { - return entries - } - projectLower := strings.ToLower(project) - taskLower := strings.ToLower(task) - var result []odoo.TimesheetEntry - for _, e := range entries { - if project != "" && !strings.Contains(strings.ToLower(e.Project), projectLower) { - continue - } - if task != "" && !strings.Contains(strings.ToLower(e.Task), taskLower) { - continue - } - if status != "" && e.ValidatedStatus != status { - continue - } - result = append(result, e) - } - return result -} - -var ( - entriesWeek string - entriesDate string - entriesProject string - entriesTask string - entriesStatus string -) - -var entriesCmd = &cobra.Command{ - Use: "entries", - Short: "List individual timesheet entries with full detail", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - var dateFrom, dateTo string - if entriesDate != "" { - dateFrom, dateTo, err = parseDateRange(entriesDate) - } else { - dateFrom, dateTo, err = weekDateRange(entriesWeek) - } - if err != nil { - return err - } - - entries, err := client.ListTimesheets(dateFrom, dateTo) - if err != nil { - return err - } - - entries = filterEntries(entries, entriesProject, entriesTask, entriesStatus) - - if entriesDate != "" { - fmt.Printf("Date: %s\n\n", dateFrom) - } else { - fmt.Printf("Week: %s to %s\n\n", dateFrom, dateTo) - } - - fmt.Printf("%-8s %-12s %-8s %-25s %-8s %-25s %-6s %-10s %s\n", - "ID", "Date", "ProjID", "Project", "TaskID", "Task", "Hours", "Status", "Description") - fmt.Printf("%-8s %-12s %-8s %-25s %-8s %-25s %-6s %-10s %s\n", - "--------", "------------", "--------", "-------------------------", "--------", "-------------------------", "------", "----------", "------------------------------") - - var total float64 - for _, e := range entries { - fmt.Printf("%-8d %-12s %-8d %-25s %-8d %-25s %-6s %-10s %s\n", - e.ID, e.Date, e.ProjectID, e.Project, e.TaskID, e.Task, tui.FormatHours(e.Hours), e.ValidatedStatus, e.Name) - total += e.Hours - } - - fmt.Printf("\nTotal: %s (%d entries)\n", tui.FormatHours(total), len(entries)) - return nil - }, -} - -// buildTimesheetWriteParams constructs and validates TimesheetWriteParams from CLI flag values. -// An empty date defaults to today. Hours accepts both decimal ("2.5") and H:MM ("2:30") formats. -func buildTimesheetWriteParams(projectID, taskID int64, date, description, hoursStr string) (odoo.TimesheetWriteParams, error) { - if date == "" { - date = time.Now().Format("2006-01-02") - } else { - if _, err := time.Parse("2006-01-02", date); err != nil { - return odoo.TimesheetWriteParams{}, fmt.Errorf("invalid date %q: expected YYYY-MM-DD", date) - } - } - hours, err := tui.ParseHours(hoursStr) - if err != nil { - return odoo.TimesheetWriteParams{}, err - } - p := odoo.TimesheetWriteParams{ - ProjectID: projectID, - TaskID: taskID, - Date: date, - Name: description, - Hours: hours, - } - if err := odoo.ValidateTimesheetParams(p); err != nil { - return odoo.TimesheetWriteParams{}, err - } - return p, nil -} - -var ( - addProjectID int64 - addTaskID int64 - addDate string - addHours string - addDescription string -) - -var entriesAddCmd = &cobra.Command{ - Use: "add", - Short: "Create a new timesheet entry", - RunE: func(cmd *cobra.Command, args []string) error { - params, err := buildTimesheetWriteParams(addProjectID, addTaskID, addDate, addDescription, addHours) - if err != nil { - return err - } - - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - id, err := client.CreateTimesheet(params) - if err != nil { - return err - } - - fmt.Printf("Created entry %d\n", id) - return nil - }, -} - -// parseEntryID parses and validates a timesheet entry ID from a string. -func parseEntryID(s string) (int64, error) { - id, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid entry ID %q: must be a positive integer", s) - } - if id <= 0 { - return 0, fmt.Errorf("invalid entry ID %q: must be a positive integer", s) - } - return id, nil -} - -var ( - updateProjectID int64 - updateTaskID int64 - updateDate string - updateHours string - updateDescription string -) - -// buildUpdateFields builds a partial Odoo field map from the flags that were -// explicitly set on the command. Returns an error if a set flag has an invalid value. -func buildUpdateFields(cmd *cobra.Command) (map[string]interface{}, error) { - fields := make(map[string]interface{}) - if cmd.Flags().Changed("project-id") { - fields["project_id"] = updateProjectID - } - if cmd.Flags().Changed("task-id") { - fields["task_id"] = updateTaskID - } - if cmd.Flags().Changed("date") { - if _, err := time.Parse("2006-01-02", updateDate); err != nil { - return nil, fmt.Errorf("invalid date %q: expected YYYY-MM-DD", updateDate) - } - fields["date"] = updateDate - } - if cmd.Flags().Changed("hours") { - h, err := tui.ParseHours(updateHours) - if err != nil { - return nil, err - } - fields["unit_amount"] = h - } - if cmd.Flags().Changed("description") { - if updateDescription == "" { - return nil, fmt.Errorf("description must not be empty") - } - fields["name"] = updateDescription - } - if len(fields) == 0 { - return nil, fmt.Errorf("at least one flag is required (--project-id, --task-id, --date, --hours, --description)") - } - return fields, nil -} - -var entriesUpdateCmd = &cobra.Command{ - Use: "update ID", - Short: "Update an existing timesheet entry", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseEntryID(args[0]) - if err != nil { - return err - } - - fields, err := buildUpdateFields(cmd) - if err != nil { - return err - } - - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - if err := client.UpdateTimesheet(id, fields); err != nil { - return err - } - - fmt.Printf("Updated entry %d\n", id) - return nil - }, -} - -var entriesDeleteCmd = &cobra.Command{ - Use: "delete ID", - Short: "Delete a timesheet entry", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseEntryID(args[0]) - if err != nil { - return err - } - - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - if err := client.DeleteTimesheet(id); err != nil { - return err - } - - fmt.Printf("Deleted entry %d\n", id) - return nil - }, -} - var fieldsCmd = &cobra.Command{ Use: "fields ", Short: "Inspect Odoo model fields", diff --git a/internal/filter/entries.go b/internal/filter/entries.go new file mode 100644 index 0000000..50b9a89 --- /dev/null +++ b/internal/filter/entries.go @@ -0,0 +1,32 @@ +package filter + +import ( + "strings" + + "github.com/seletz/odoo-work-cli/internal/odoo" +) + +// Entries returns entries matching the project, task, and status filters. +// Project and task use case-insensitive substring match. Status uses exact match. +// Empty filter matches all. +func Entries(entries []odoo.TimesheetEntry, project, task, status string) []odoo.TimesheetEntry { + if project == "" && task == "" && status == "" { + return entries + } + projectLower := strings.ToLower(project) + taskLower := strings.ToLower(task) + var result []odoo.TimesheetEntry + for _, e := range entries { + if project != "" && !strings.Contains(strings.ToLower(e.Project), projectLower) { + continue + } + if task != "" && !strings.Contains(strings.ToLower(e.Task), taskLower) { + continue + } + if status != "" && e.ValidatedStatus != status { + continue + } + result = append(result, e) + } + return result +} diff --git a/internal/parsing/times.go b/internal/parsing/times.go new file mode 100644 index 0000000..e6a03ef --- /dev/null +++ b/internal/parsing/times.go @@ -0,0 +1,29 @@ +package parsing + +import ( + "fmt" + "time" + + "github.com/seletz/odoo-work-cli/internal/tui" +) + +// ParseDateRange returns a single-day date range for the given YYYY-MM-DD string. +func ParseDateRange(date string) (string, string, error) { + d, err := time.Parse("2006-01-02", date) + if err != nil { + return "", "", fmt.Errorf("invalid date %q: expected YYYY-MM-DD", date) + } + s := d.Format("2006-01-02") + return s, s, nil +} + +// WeekDateRange returns the Monday and Sunday of the ISO week specified +// as "2006-W02" format, or the current week if empty. +func WeekDateRange(week string) (string, string, error) { + monday, err := tui.ParseWeekMonday(week) + if err != nil { + return "", "", err + } + sunday := monday.AddDate(0, 0, 6) + return monday.Format("2006-01-02"), sunday.Format("2006-01-02"), nil +} From 138b2d419e3ae2585e1712d9bbd148e4d85566c1 Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 16:04:06 +0100 Subject: [PATCH 03/12] Refactor main command initialization by modularizing commands and moving them into dedicated packages. Simplify `rootCmd` setup and reconfigure persistent pre-run logic. --- cmd/clock/clock.go | 2 +- cmd/config/config.go | 55 +++++++ cmd/config/install.go | 54 +++++++ cmd/fields/fields.go | 32 ++++ cmd/odoo-work-cli/main.go | 323 ++++--------------------------------- cmd/project/projects.go | 58 +++++++ cmd/tasks/tasks.go | 39 +++++ cmd/timesheet/timesheet.go | 45 ++++++ cmd/tui/tui.go | 34 ++++ 9 files changed, 349 insertions(+), 293 deletions(-) create mode 100644 cmd/config/config.go create mode 100644 cmd/config/install.go create mode 100644 cmd/fields/fields.go create mode 100644 cmd/project/projects.go create mode 100644 cmd/tasks/tasks.go create mode 100644 cmd/timesheet/timesheet.go create mode 100644 cmd/tui/tui.go diff --git a/cmd/clock/clock.go b/cmd/clock/clock.go index 63b8e3a..785f9d4 100644 --- a/cmd/clock/clock.go +++ b/cmd/clock/clock.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" ) -func Cmd(client *odoo.XMLRPCClient) *cobra.Command { +func CMD(client *odoo.XMLRPCClient) *cobra.Command { cmd := &cobra.Command{ Use: "clock", Short: "Clock in/out and attendance status", diff --git a/cmd/config/config.go b/cmd/config/config.go new file mode 100644 index 0000000..5ee5edb --- /dev/null +++ b/cmd/config/config.go @@ -0,0 +1,55 @@ +package project + +import ( + "bytes" + "fmt" + + "github.com/BurntSushi/toml" + "github.com/seletz/odoo-work-cli/internal/config" + "github.com/spf13/cobra" +) + +var configMerged bool + +func CMD(cfgFile string) *cobra.Command { + + cmd := &cobra.Command{ + Use: "config", + Short: "Show discovered config files and merged configuration", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + result, err := config.Discover(cfgFile) + if err != nil { + return err + } + + if !configMerged { + if len(result.Files) == 0 { + fmt.Println("No config files discovered.") + } else { + fmt.Println("Discovered config files (merge order):") + for _, f := range result.Files { + fmt.Printf(" %s\n", f) + } + } + return nil + } + + // Password is tagged toml:"-" on Config, so the encoder + // omits it automatically — output is a valid config file. + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(result.Config); err != nil { + return fmt.Errorf("encoding config: %w", err) + } + fmt.Print(buf.String()) + return nil + }, + } + + cmd.Flags().BoolVar(&configMerged, "merged", false, "print merged TOML config (password redacted)") + + cmd.AddCommand(installcmd()) + return cmd +} diff --git a/cmd/config/install.go b/cmd/config/install.go new file mode 100644 index 0000000..8e906bb --- /dev/null +++ b/cmd/config/install.go @@ -0,0 +1,54 @@ +package project + +import ( + "fmt" + "strings" + + "github.com/seletz/odoo-work-cli/internal/config" + "github.com/spf13/cobra" +) + +func installcmd() *cobra.Command { + + cmd := &cobra.Command{ + Use: "install", + Short: "Create a default config file in the platform config directory", + RunE: func(cmd *cobra.Command, args []string) error { + path, err := config.DefaultConfigPath() + if err != nil { + return err + } + + fmt.Printf("This will create a default config file at:\n %s\n\n", path) + + if !confirmPrompt("Continue?") { + fmt.Println("Aborted.") + return nil + } + + if err := config.InstallConfig(path); err != nil { + return err + } + + fmt.Printf("Config file created at: %s\n", path) + fmt.Println("Edit it to set your Odoo URL, database, and username.") + fmt.Println("Set ODOO_PASSWORD via environment variable (see .env.1p).") + return nil + }, + } + + cmd.Flags().BoolVar(&configMerged, "merged", false, "print merged TOML config (password redacted)") + + return cmd +} + +// confirmPrompt prints msg and reads y/N from stdin. +func confirmPrompt(msg string) bool { + fmt.Printf("%s [y/N] ", msg) + var response string + if _, err := fmt.Scanln(&response); err != nil { + return false + } + response = strings.TrimSpace(strings.ToLower(response)) + return response == "y" || response == "yes" +} diff --git a/cmd/fields/fields.go b/cmd/fields/fields.go new file mode 100644 index 0000000..a5aa832 --- /dev/null +++ b/cmd/fields/fields.go @@ -0,0 +1,32 @@ +package fields + +import ( + "fmt" + + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/spf13/cobra" +) + +var tsWeek string + +func CMD(client *odoo.XMLRPCClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "fields ", + Short: "Inspect Odoo model fields", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + fields, err := client.GetFields(args[0]) + if err != nil { + return err + } + fmt.Printf("%-30s %-15s %-30s %s\n", "Field", "Type", "Label", "Required") + fmt.Printf("%-30s %-15s %-30s %s\n", "------------------------------", "---------------", "------------------------------", "--------") + for _, f := range fields { + fmt.Printf("%-30s %-15s %-30s %v\n", f.Name, f.Type, f.String, f.Required) + } + return nil + }, + } + + return cmd +} diff --git a/cmd/odoo-work-cli/main.go b/cmd/odoo-work-cli/main.go index d8e61e4..ab1bec5 100644 --- a/cmd/odoo-work-cli/main.go +++ b/cmd/odoo-work-cli/main.go @@ -1,43 +1,48 @@ package main import ( - "bytes" "fmt" "os" - "strconv" - "strings" - tea "charm.land/bubbletea/v2" - "github.com/BurntSushi/toml" "github.com/seletz/odoo-work-cli/cmd/clock" + configcmd "github.com/seletz/odoo-work-cli/cmd/config" "github.com/seletz/odoo-work-cli/cmd/entries" + "github.com/seletz/odoo-work-cli/cmd/fields" + "github.com/seletz/odoo-work-cli/cmd/project" + "github.com/seletz/odoo-work-cli/cmd/tasks" + "github.com/seletz/odoo-work-cli/cmd/timesheet" + "github.com/seletz/odoo-work-cli/cmd/tui" "github.com/seletz/odoo-work-cli/internal/config" "github.com/seletz/odoo-work-cli/internal/odoo" - "github.com/seletz/odoo-work-cli/internal/parsing" - "github.com/seletz/odoo-work-cli/internal/tui" + "github.com/seletz/odoo-work-cli/internal/version" "github.com/spf13/cobra" ) var cfgFile string +var cfg *config.Config +var client *odoo.XMLRPCClient func main() { - cfg, err := loadConfig() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - client, err := newClient(cfg) - if err != nil { - fmt.Println(err) - os.Exit(1) - } - rootCmd := &cobra.Command{ Use: "odoo-work-cli", Short: "CLI for managing Odoo 17 timesheets", Version: version.Version, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var err error + cfg, err = loadConfig() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + client, err = newClient(cfg) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + return err + }, PersistentPostRun: func(cmd *cobra.Command, args []string) { if client != nil { @@ -48,30 +53,21 @@ func main() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path (skip discovery)") - rootCmd.AddCommand(projectsCmd) - rootCmd.AddCommand(tasksCmd) - rootCmd.AddCommand(timesheetsCmd) - rootCmd.AddCommand(fieldsCmd) + rootCmd.AddCommand(project.CMD(client, cfg)) + rootCmd.AddCommand(tasks.CMD(client)) + rootCmd.AddCommand(timesheet.CMD(client)) + rootCmd.AddCommand(fields.CMD(client)) rootCmd.AddCommand(whoamiCmd) - rootCmd.AddCommand(configCmd) - rootCmd.AddCommand(tuiCmd) + rootCmd.AddCommand(configcmd.CMD(cfgFile)) + rootCmd.AddCommand(tui.CMD(client, cfg)) rootCmd.AddCommand(entries.CMD(client)) - rootCmd.AddCommand(clock.Cmd(client)) + rootCmd.AddCommand(clock.CMD(client)) - if err = rootCmd.Execute(); err != nil { + if err := rootCmd.Execute(); err != nil { os.Exit(1) } } -func init() { - - timesheetsCmd.Flags().StringVar(&tsWeek, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") - - tuiCmd.Flags().StringVar(&tuiWeek, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") - configCmd.Flags().BoolVar(&configMerged, "merged", false, "print merged TOML config (password redacted)") - configCmd.AddCommand(configInstallCmd) -} - // loadConfig loads and merges config using file discovery and env vars. func loadConfig() (*config.Config, error) { result, err := config.Discover(cfgFile) @@ -89,163 +85,6 @@ func newClient(cfg *config.Config) (*odoo.XMLRPCClient, error) { return odoo.NewXMLRPCClient(cfg.URL, cfg.Database, cfg.Username, cfg.Password, cfg.WebPassword, cfg.TOTPSecret, cfg.Models) } -var projectsCmd = &cobra.Command{ - Use: "projects", - Short: "List Odoo projects", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - projects, err := client.ListProjects() - if err != nil { - return err - } - - // Build dynamic column headers from config extra fields. - var extraNames []string - if mc, ok := cfg.Models["project"]; ok { - for _, ef := range mc.ExtraFields { - extraNames = append(extraNames, ef.Name) - } - } - - // Print header. - header := fmt.Sprintf("%-6s %-30s %-20s %-15s %-15s %-20s", - "ID", "Name", "Customer", "Company", "Phase", "Project Manager") - sep := fmt.Sprintf("%-6s %-30s %-20s %-15s %-15s %-20s", - "------", "------------------------------", "--------------------", "---------------", "---------------", "--------------------") - for _, name := range extraNames { - label := strings.ReplaceAll(name, "_", " ") - header += fmt.Sprintf(" %-20s", label) - sep += fmt.Sprintf(" %-20s", "--------------------") - } - header += fmt.Sprintf(" %s", "Active") - sep += fmt.Sprintf(" %s", "------") - fmt.Println(header) - fmt.Println(sep) - - for _, p := range projects { - line := fmt.Sprintf("%-6d %-30s %-20s %-15s %-15s %-20s", - p.ID, p.Name, p.Customer, p.Company, p.Stage, p.ProjectManager) - for _, name := range extraNames { - line += fmt.Sprintf(" %-20s", p.ExtraFields[name]) - } - line += fmt.Sprintf(" %v", p.Active) - fmt.Println(line) - } - return nil - }, -} - -var tasksCmd = &cobra.Command{ - Use: "tasks [project-id]", - Short: "List Odoo tasks", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - var projectID int64 - if len(args) == 1 { - projectID, err = strconv.ParseInt(args[0], 10, 64) - if err != nil { - return fmt.Errorf("invalid project ID: %w", err) - } - } - - tasks, err := client.ListTasks(projectID) - if err != nil { - return err - } - fmt.Printf("%-8s %-40s %-30s %s\n", "ID", "Name", "Project", "Stage") - fmt.Printf("%-8s %-40s %-30s %s\n", "--------", "----------------------------------------", "------------------------------", "--------------------") - for _, t := range tasks { - fmt.Printf("%-8d %-40s %-30s %s\n", t.ID, t.Name, t.Project, t.Stage) - } - return nil - }, -} - -var tsWeek string - -var timesheetsCmd = &cobra.Command{ - Use: "timesheets", - Short: "List Odoo timesheets for a week", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - dateFrom, dateTo, err := parsing.WeekDateRange(tsWeek) - if err != nil { - return err - } - - entries, err := client.ListTimesheets(dateFrom, dateTo) - if err != nil { - return err - } - fmt.Printf("Week: %s to %s\n\n", dateFrom, dateTo) - fmt.Printf("%-8s %-12s %-8s %-25s %-8s %-25s %-30s %s\n", "ID", "Date", "ProjID", "Project", "TaskID", "Task", "Description", "Hours") - fmt.Printf("%-8s %-12s %-8s %-25s %-8s %-25s %-30s %s\n", - "--------", "------------", "--------", "-------------------------", "--------", "-------------------------", "------------------------------", "-----") - var total float64 - for _, e := range entries { - fmt.Printf("%-8d %-12s %-8d %-25s %-8d %-25s %-30s %.2f\n", e.ID, e.Date, e.ProjectID, e.Project, e.TaskID, e.Task, e.Name, e.Hours) - total += e.Hours - } - fmt.Printf("\nTotal: %.2f hours\n", total) - return nil - }, -} - -var fieldsCmd = &cobra.Command{ - Use: "fields ", - Short: "Inspect Odoo model fields", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - fields, err := client.GetFields(args[0]) - if err != nil { - return err - } - fmt.Printf("%-30s %-15s %-30s %s\n", "Field", "Type", "Label", "Required") - fmt.Printf("%-30s %-15s %-30s %s\n", "------------------------------", "---------------", "------------------------------", "--------") - for _, f := range fields { - fmt.Printf("%-30s %-15s %-30s %v\n", f.Name, f.Type, f.String, f.Required) - } - return nil - }, -} - var whoamiCmd = &cobra.Command{ Use: "whoami", Short: "Show current Odoo user info", @@ -271,103 +110,3 @@ var whoamiCmd = &cobra.Command{ return nil }, } - -var tuiWeek string - -var tuiCmd = &cobra.Command{ - Use: "tui", - Short: "Interactive weekly timesheet view", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - - monday, err := tui.ParseWeekMonday(tuiWeek) - if err != nil { - return err - } - - m := tui.NewModel(client, tui.MondayTime{Time: monday}, cfg.Hours, cfg.Bundesland, cfg.Keys, cfg.CompanyColors) - p := tea.NewProgram(m) - _, err = p.Run() - return err - }, -} - -var configMerged bool - -var configCmd = &cobra.Command{ - Use: "config", - Short: "Show discovered config files and merged configuration", - RunE: func(cmd *cobra.Command, args []string) error { - result, err := config.Discover(cfgFile) - if err != nil { - return err - } - - if !configMerged { - if len(result.Files) == 0 { - fmt.Println("No config files discovered.") - } else { - fmt.Println("Discovered config files (merge order):") - for _, f := range result.Files { - fmt.Printf(" %s\n", f) - } - } - return nil - } - - // Password is tagged toml:"-" on Config, so the encoder - // omits it automatically — output is a valid config file. - var buf bytes.Buffer - if err := toml.NewEncoder(&buf).Encode(result.Config); err != nil { - return fmt.Errorf("encoding config: %w", err) - } - fmt.Print(buf.String()) - return nil - }, -} - -var configInstallCmd = &cobra.Command{ - Use: "install", - Short: "Create a default config file in the platform config directory", - RunE: func(cmd *cobra.Command, args []string) error { - path, err := config.DefaultConfigPath() - if err != nil { - return err - } - - fmt.Printf("This will create a default config file at:\n %s\n\n", path) - - if !confirmPrompt("Continue?") { - fmt.Println("Aborted.") - return nil - } - - if err := config.InstallConfig(path); err != nil { - return err - } - - fmt.Printf("Config file created at: %s\n", path) - fmt.Println("Edit it to set your Odoo URL, database, and username.") - fmt.Println("Set ODOO_PASSWORD via environment variable (see .env.1p).") - return nil - }, -} - -// confirmPrompt prints msg and reads y/N from stdin. -func confirmPrompt(msg string) bool { - fmt.Printf("%s [y/N] ", msg) - var response string - if _, err := fmt.Scanln(&response); err != nil { - return false - } - response = strings.TrimSpace(strings.ToLower(response)) - return response == "y" || response == "yes" -} diff --git a/cmd/project/projects.go b/cmd/project/projects.go new file mode 100644 index 0000000..5bb61a6 --- /dev/null +++ b/cmd/project/projects.go @@ -0,0 +1,58 @@ +package project + +import ( + "fmt" + "strings" + + "github.com/seletz/odoo-work-cli/internal/config" + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/spf13/cobra" +) + +func CMD(client *odoo.XMLRPCClient, cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "projects", + Short: "List Odoo projects", + RunE: func(cmd *cobra.Command, args []string) error { + projects, err := client.ListProjects() + if err != nil { + return err + } + + // Build dynamic column headers from config extra fields. + var extraNames []string + if mc, ok := cfg.Models["project"]; ok { + for _, ef := range mc.ExtraFields { + extraNames = append(extraNames, ef.Name) + } + } + + // Print header. + header := fmt.Sprintf("%-6s %-30s %-20s %-15s %-15s %-20s", + "ID", "Name", "Customer", "Company", "Phase", "Project Manager") + sep := fmt.Sprintf("%-6s %-30s %-20s %-15s %-15s %-20s", + "------", "------------------------------", "--------------------", "---------------", "---------------", "--------------------") + for _, name := range extraNames { + label := strings.ReplaceAll(name, "_", " ") + header += fmt.Sprintf(" %-20s", label) + sep += fmt.Sprintf(" %-20s", "--------------------") + } + header += fmt.Sprintf(" %s", "Active") + sep += fmt.Sprintf(" %s", "------") + fmt.Println(header) + fmt.Println(sep) + + for _, p := range projects { + line := fmt.Sprintf("%-6d %-30s %-20s %-15s %-15s %-20s", + p.ID, p.Name, p.Customer, p.Company, p.Stage, p.ProjectManager) + for _, name := range extraNames { + line += fmt.Sprintf(" %-20s", p.ExtraFields[name]) + } + line += fmt.Sprintf(" %v", p.Active) + fmt.Println(line) + } + return nil + }, + } + return cmd +} diff --git a/cmd/tasks/tasks.go b/cmd/tasks/tasks.go new file mode 100644 index 0000000..759191e --- /dev/null +++ b/cmd/tasks/tasks.go @@ -0,0 +1,39 @@ +package tasks + +import ( + "fmt" + "strconv" + + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/spf13/cobra" +) + +func CMD(client *odoo.XMLRPCClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "tasks [project-id]", + Short: "List Odoo tasks", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var err error + var projectID int64 + if len(args) == 1 { + projectID, err = strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid project ID: %w", err) + } + } + + tasks, err := client.ListTasks(projectID) + if err != nil { + return err + } + fmt.Printf("%-8s %-40s %-30s %s\n", "ID", "Name", "Project", "Stage") + fmt.Printf("%-8s %-40s %-30s %s\n", "--------", "----------------------------------------", "------------------------------", "--------------------") + for _, t := range tasks { + fmt.Printf("%-8d %-40s %-30s %s\n", t.ID, t.Name, t.Project, t.Stage) + } + return nil + }, + } + return cmd +} diff --git a/cmd/timesheet/timesheet.go b/cmd/timesheet/timesheet.go new file mode 100644 index 0000000..2b949e0 --- /dev/null +++ b/cmd/timesheet/timesheet.go @@ -0,0 +1,45 @@ +package timesheet + +import ( + "fmt" + + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/parsing" + "github.com/spf13/cobra" +) + +var tsWeek string + +func CMD(client *odoo.XMLRPCClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "timesheets", + Short: "List Odoo timesheets for a week", + RunE: func(cmd *cobra.Command, args []string) error { + + dateFrom, dateTo, err := parsing.WeekDateRange(tsWeek) + if err != nil { + return err + } + + entries, err := client.ListTimesheets(dateFrom, dateTo) + if err != nil { + return err + } + fmt.Printf("Week: %s to %s\n\n", dateFrom, dateTo) + fmt.Printf("%-8s %-12s %-8s %-25s %-8s %-25s %-30s %s\n", "ID", "Date", "ProjID", "Project", "TaskID", "Task", "Description", "Hours") + fmt.Printf("%-8s %-12s %-8s %-25s %-8s %-25s %-30s %s\n", + "--------", "------------", "--------", "-------------------------", "--------", "-------------------------", "------------------------------", "-----") + var total float64 + for _, e := range entries { + fmt.Printf("%-8d %-12s %-8d %-25s %-8d %-25s %-30s %.2f\n", e.ID, e.Date, e.ProjectID, e.Project, e.TaskID, e.Task, e.Name, e.Hours) + total += e.Hours + } + fmt.Printf("\nTotal: %.2f hours\n", total) + return nil + }, + } + + cmd.Flags().StringVar(&tsWeek, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") + + return cmd +} diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go new file mode 100644 index 0000000..8288b6f --- /dev/null +++ b/cmd/tui/tui.go @@ -0,0 +1,34 @@ +package tui + +import ( + tea "charm.land/bubbletea/v2" + "github.com/seletz/odoo-work-cli/internal/config" + "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/tui" + "github.com/spf13/cobra" +) + +var tuiWeek string + +func CMD(client *odoo.XMLRPCClient, cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "tui", + Short: "Interactive weekly timesheet view", + RunE: func(cmd *cobra.Command, args []string) error { + + monday, err := tui.ParseWeekMonday(tuiWeek) + if err != nil { + return err + } + + m := tui.NewModel(client, tui.MondayTime{Time: monday}, cfg.Hours, cfg.Bundesland, cfg.Keys, cfg.CompanyColors) + p := tea.NewProgram(m) + _, err = p.Run() + return err + }, + } + + cmd.Flags().StringVar(&tuiWeek, "week", "", "ISO week (e.g. 2026-W10), defaults to current week") + + return cmd +} From 41ce49b4354ebf0fe978257d5d7a91f045674331 Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 16:07:15 +0100 Subject: [PATCH 04/12] Remove unused `tsWeek` variable from fields command. --- cmd/fields/fields.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/fields/fields.go b/cmd/fields/fields.go index a5aa832..708608f 100644 --- a/cmd/fields/fields.go +++ b/cmd/fields/fields.go @@ -7,8 +7,6 @@ import ( "github.com/spf13/cobra" ) -var tsWeek string - func CMD(client *odoo.XMLRPCClient) *cobra.Command { cmd := &cobra.Command{ Use: "fields ", From 3ca0e48bfaae2eff1705f40ae7f64be57a30ce01 Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 16:26:30 +0100 Subject: [PATCH 05/12] Refactor test organization: split `main_test.go` into dedicated packages for entries, filter, and parsing. --- .../main_test.go => entries/entries_test.go} | 285 +++++------------- internal/filter/entries_test.go | 79 +++++ internal/parsing/times_test.go | 108 +++++++ 3 files changed, 255 insertions(+), 217 deletions(-) rename cmd/{odoo-work-cli/main_test.go => entries/entries_test.go} (53%) create mode 100644 internal/filter/entries_test.go create mode 100644 internal/parsing/times_test.go diff --git a/cmd/odoo-work-cli/main_test.go b/cmd/entries/entries_test.go similarity index 53% rename from cmd/odoo-work-cli/main_test.go rename to cmd/entries/entries_test.go index a14a868..f7c26bb 100644 --- a/cmd/odoo-work-cli/main_test.go +++ b/cmd/entries/entries_test.go @@ -1,118 +1,13 @@ -package main +package entries import ( + "strings" "testing" "time" - "github.com/seletz/odoo-work-cli/internal/odoo" "github.com/spf13/cobra" ) -func TestWeekDateRange(t *testing.T) { - tests := []struct { - name string - week string - wantFrom string - wantTo string - wantErr bool - }{ - { - name: "2026-W10", - week: "2026-W10", - wantFrom: "2026-03-02", - wantTo: "2026-03-08", - }, - { - name: "2026-W01", - week: "2026-W01", - wantFrom: "2025-12-29", - wantTo: "2026-01-04", - }, - { - name: "2025-W52", - week: "2025-W52", - wantFrom: "2025-12-22", - wantTo: "2025-12-28", - }, - { - name: "invalid format", - week: "not-a-week", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - from, to, err := weekDateRange(tt.week) - - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if from != tt.wantFrom { - t.Errorf("from = %q, want %q", from, tt.wantFrom) - } - if to != tt.wantTo { - t.Errorf("to = %q, want %q", to, tt.wantTo) - } - }) - } -} - -func TestParseDateRange(t *testing.T) { - tests := []struct { - name string - date string - wantFrom string - wantTo string - wantErr bool - }{ - { - name: "valid date", - date: "2026-03-05", - wantFrom: "2026-03-05", - wantTo: "2026-03-05", - }, - { - name: "invalid date", - date: "not-a-date", - wantErr: true, - }, - { - name: "wrong format", - date: "05/03/2026", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - from, to, err := parseDateRange(tt.date) - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if from != tt.wantFrom { - t.Errorf("from = %q, want %q", from, tt.wantFrom) - } - if to != tt.wantTo { - t.Errorf("to = %q, want %q", to, tt.wantTo) - } - }) - } -} - func TestBuildTimesheetWriteParams(t *testing.T) { tests := []struct { name string @@ -252,97 +147,120 @@ func TestBuildTimesheetWriteParams(t *testing.T) { func TestBuildUpdateFields(t *testing.T) { tests := []struct { - name string - flags []string - wantFields []string - wantErr bool + name string + flags []string + ops subOps + wantFields []string + wantValues map[string]interface{} + wantErr bool + wantErrSubstr string }{ { name: "single field hours", flags: []string{"--hours", "2.5"}, + ops: subOps{hours: "2.5"}, wantFields: []string{"unit_amount"}, + wantValues: map[string]interface{}{"unit_amount": 2.5}, }, { name: "single field description", flags: []string{"--description", "new desc"}, + ops: subOps{description: "new desc"}, wantFields: []string{"name"}, + wantValues: map[string]interface{}{"name": "new desc"}, }, { name: "single field date", flags: []string{"--date", "2026-03-09"}, + ops: subOps{date: "2026-03-09"}, wantFields: []string{"date"}, + wantValues: map[string]interface{}{"date": "2026-03-09"}, }, { name: "single field project-id", flags: []string{"--project-id", "42"}, + ops: subOps{projectID: 42}, wantFields: []string{"project_id"}, + wantValues: map[string]interface{}{"project_id": int64(42)}, + }, + { + name: "single field task-id", + flags: []string{"--task-id", "7"}, + ops: subOps{taskID: 7}, + wantFields: []string{"task_id"}, + wantValues: map[string]interface{}{"task_id": int64(7)}, }, { name: "hours H:MM format", flags: []string{"--hours", "1:30"}, + ops: subOps{hours: "1:30"}, wantFields: []string{"unit_amount"}, + wantValues: map[string]interface{}{"unit_amount": 1.5}, }, { name: "multiple fields", flags: []string{"--hours", "3.0", "--description", "updated"}, + ops: subOps{hours: "3.0", description: "updated"}, wantFields: []string{"unit_amount", "name"}, + wantValues: map[string]interface{}{"unit_amount": 3.0, "name": "updated"}, }, { - name: "no flags errors", - flags: []string{}, - wantErr: true, + name: "no flags errors", + flags: []string{}, + wantErr: true, + wantErrSubstr: "at least one flag is required", }, { - name: "invalid date", - flags: []string{"--date", "not-a-date"}, - wantErr: true, + name: "invalid date", + flags: []string{"--date", "not-a-date"}, + ops: subOps{date: "not-a-date"}, + wantErr: true, + wantErrSubstr: `invalid date "not-a-date": expected YYYY-MM-DD`, }, { - name: "zero hours", - flags: []string{"--hours", "0"}, - wantErr: true, + name: "zero hours", + flags: []string{"--hours", "0"}, + ops: subOps{hours: "0"}, + wantErr: true, + wantErrSubstr: "hours must be greater than zero", }, { - name: "negative hours", - flags: []string{"--hours", "-1"}, - wantErr: true, + name: "negative hours", + flags: []string{"--hours", "-1"}, + ops: subOps{hours: "-1"}, + wantErr: true, + wantErrSubstr: "hours must be greater than zero", }, { - name: "empty description", - flags: []string{"--description", ""}, - wantErr: true, + name: "empty description", + flags: []string{"--description", ""}, + ops: subOps{description: ""}, + wantErr: true, + wantErrSubstr: "description must not be empty", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create a fresh command with the same flags as entriesUpdateCmd. cmd := &cobra.Command{Use: "test"} - var pID, tID int64 - var date, desc, hours string - cmd.Flags().Int64Var(&pID, "project-id", 0, "") - cmd.Flags().Int64Var(&tID, "task-id", 0, "") - cmd.Flags().StringVar(&date, "date", "", "") - cmd.Flags().StringVar(&hours, "hours", "", "") - cmd.Flags().StringVar(&desc, "description", "", "") + cmd.Flags().Int64("project-id", 0, "") + cmd.Flags().Int64("task-id", 0, "") + cmd.Flags().String("date", "", "") + cmd.Flags().String("hours", "", "") + cmd.Flags().String("description", "", "") - err := cmd.ParseFlags(tt.flags) - if err != nil { + if err := cmd.ParseFlags(tt.flags); err != nil { t.Fatalf("parsing flags: %v", err) } - // Point the package-level vars at the parsed values. - updateProjectID = pID - updateTaskID = tID - updateDate = date - updateHours = hours - updateDescription = desc - - fields, err := buildUpdateFields(cmd) + fields, err := buildUpdateFields(cmd, &tt.ops) if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") } + if tt.wantErrSubstr != "" && !strings.Contains(err.Error(), tt.wantErrSubstr) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr) + } return } if err != nil { @@ -352,8 +270,13 @@ func TestBuildUpdateFields(t *testing.T) { t.Fatalf("got %d fields, want %d", len(fields), len(tt.wantFields)) } for _, key := range tt.wantFields { - if _, ok := fields[key]; !ok { + got, ok := fields[key] + if !ok { t.Errorf("missing expected field %q", key) + continue + } + if want, ok := tt.wantValues[key]; ok && got != want { + t.Errorf("field %q = %#v, want %#v", key, got, want) } } }) @@ -392,75 +315,3 @@ func TestParseEntryID(t *testing.T) { }) } } - -func TestFilterEntries(t *testing.T) { - entries := []odoo.TimesheetEntry{ - {ID: 1, Date: "2026-03-02", Project: "Acme Corp", Task: "Backend Dev", Name: "Auth endpoint", Hours: 2.0, ValidatedStatus: "draft"}, - {ID: 2, Date: "2026-03-02", Project: "Acme Corp", Task: "QA Testing", Name: "Review PR", Hours: 1.5, ValidatedStatus: "validated"}, - {ID: 3, Date: "2026-03-03", Project: "Beta Project", Task: "Frontend Dev", Name: "Dashboard", Hours: 4.0, ValidatedStatus: "draft"}, - } - - tests := []struct { - name string - project string - task string - status string - wantIDs []int64 - }{ - { - name: "no filter", - wantIDs: []int64{1, 2, 3}, - }, - { - name: "filter by project", - project: "acme", - wantIDs: []int64{1, 2}, - }, - { - name: "filter by task", - task: "dev", - wantIDs: []int64{1, 3}, - }, - { - name: "filter by both", - project: "acme", - task: "qa", - wantIDs: []int64{2}, - }, - { - name: "no match", - project: "nonexistent", - wantIDs: nil, - }, - { - name: "filter by status draft", - status: "draft", - wantIDs: []int64{1, 3}, - }, - { - name: "filter by status validated", - status: "validated", - wantIDs: []int64{2}, - }, - { - name: "filter by status and project", - project: "acme", - status: "draft", - wantIDs: []int64{1}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := filterEntries(entries, tt.project, tt.task, tt.status) - if len(got) != len(tt.wantIDs) { - t.Fatalf("got %d entries, want %d", len(got), len(tt.wantIDs)) - } - for i, e := range got { - if e.ID != tt.wantIDs[i] { - t.Errorf("entry[%d].ID = %d, want %d", i, e.ID, tt.wantIDs[i]) - } - } - }) - } -} diff --git a/internal/filter/entries_test.go b/internal/filter/entries_test.go new file mode 100644 index 0000000..80c8b27 --- /dev/null +++ b/internal/filter/entries_test.go @@ -0,0 +1,79 @@ +package filter + +import ( + "testing" + + "github.com/seletz/odoo-work-cli/internal/odoo" +) + +func TestEntries(t *testing.T) { + entries := []odoo.TimesheetEntry{ + {ID: 1, Date: "2026-03-02", Project: "Acme Corp", Task: "Backend Dev", Name: "Auth endpoint", Hours: 2.0, ValidatedStatus: "draft"}, + {ID: 2, Date: "2026-03-02", Project: "Acme Corp", Task: "QA Testing", Name: "Review PR", Hours: 1.5, ValidatedStatus: "validated"}, + {ID: 3, Date: "2026-03-03", Project: "Beta Project", Task: "Frontend Dev", Name: "Dashboard", Hours: 4.0, ValidatedStatus: "draft"}, + } + + tests := []struct { + name string + project string + task string + status string + wantIDs []int64 + }{ + { + name: "no filter", + wantIDs: []int64{1, 2, 3}, + }, + { + name: "filter by project", + project: "acme", + wantIDs: []int64{1, 2}, + }, + { + name: "filter by task", + task: "dev", + wantIDs: []int64{1, 3}, + }, + { + name: "filter by both", + project: "acme", + task: "qa", + wantIDs: []int64{2}, + }, + { + name: "no match", + project: "nonexistent", + wantIDs: nil, + }, + { + name: "filter by status draft", + status: "draft", + wantIDs: []int64{1, 3}, + }, + { + name: "filter by status validated", + status: "validated", + wantIDs: []int64{2}, + }, + { + name: "filter by status and project", + project: "acme", + status: "draft", + wantIDs: []int64{1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Entries(entries, tt.project, tt.task, tt.status) + if len(got) != len(tt.wantIDs) { + t.Fatalf("got %d entries, want %d", len(got), len(tt.wantIDs)) + } + for i, entry := range got { + if entry.ID != tt.wantIDs[i] { + t.Errorf("entry[%d].ID = %d, want %d", i, entry.ID, tt.wantIDs[i]) + } + } + }) + } +} diff --git a/internal/parsing/times_test.go b/internal/parsing/times_test.go new file mode 100644 index 0000000..f0a3978 --- /dev/null +++ b/internal/parsing/times_test.go @@ -0,0 +1,108 @@ +package parsing + +import "testing" + +func TestWeekDateRange(t *testing.T) { + tests := []struct { + name string + week string + wantFrom string + wantTo string + wantErr bool + }{ + { + name: "2026-W10", + week: "2026-W10", + wantFrom: "2026-03-02", + wantTo: "2026-03-08", + }, + { + name: "2026-W01", + week: "2026-W01", + wantFrom: "2025-12-29", + wantTo: "2026-01-04", + }, + { + name: "2025-W52", + week: "2025-W52", + wantFrom: "2025-12-22", + wantTo: "2025-12-28", + }, + { + name: "invalid format", + week: "not-a-week", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + from, to, err := WeekDateRange(tt.week) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if from != tt.wantFrom { + t.Errorf("from = %q, want %q", from, tt.wantFrom) + } + if to != tt.wantTo { + t.Errorf("to = %q, want %q", to, tt.wantTo) + } + }) + } +} + +func TestParseDateRange(t *testing.T) { + tests := []struct { + name string + date string + wantFrom string + wantTo string + wantErr bool + }{ + { + name: "valid date", + date: "2026-03-05", + wantFrom: "2026-03-05", + wantTo: "2026-03-05", + }, + { + name: "invalid date", + date: "not-a-date", + wantErr: true, + }, + { + name: "wrong format", + date: "05/03/2026", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + from, to, err := ParseDateRange(tt.date) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if from != tt.wantFrom { + t.Errorf("from = %q, want %q", from, tt.wantFrom) + } + if to != tt.wantTo { + t.Errorf("to = %q, want %q", to, tt.wantTo) + } + }) + } +} From 9fbd188b1c83e067d230722317e394eead37976b Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 17:05:52 +0100 Subject: [PATCH 06/12] Refactor CLI commands to use unified `app.Deps` structure for dependency injection and simplify client and config handling. --- cmd/clock/clock.go | 10 ++--- cmd/clock/in.go | 10 +++-- cmd/clock/out.go | 9 +++- cmd/clock/status.go | 9 +++- cmd/config/config.go | 4 +- cmd/entries/add.go | 9 +++- cmd/entries/delete.go | 9 +++- cmd/entries/entries.go | 15 ++++--- cmd/entries/update.go | 9 +++- cmd/fields/fields.go | 9 +++- cmd/odoo-work-cli/main.go | 87 +++++++++++++++++++------------------- cmd/project/projects.go | 14 ++++-- cmd/tasks/tasks.go | 10 +++-- cmd/timesheet/timesheet.go | 8 +++- cmd/tui/tui.go | 13 ++++-- 15 files changed, 143 insertions(+), 82 deletions(-) diff --git a/cmd/clock/clock.go b/cmd/clock/clock.go index 785f9d4..82c65ad 100644 --- a/cmd/clock/clock.go +++ b/cmd/clock/clock.go @@ -1,19 +1,19 @@ package clock import ( - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/spf13/cobra" ) -func CMD(client *odoo.XMLRPCClient) *cobra.Command { +func CMD(deps *app.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "clock", Short: "Clock in/out and attendance status", } - cmd.AddCommand(inCMD(client)) - cmd.AddCommand(outCMD(client)) - cmd.AddCommand(statusCMD(client)) + cmd.AddCommand(inCMD(deps)) + cmd.AddCommand(outCMD(deps)) + cmd.AddCommand(statusCMD(deps)) return cmd } diff --git a/cmd/clock/in.go b/cmd/clock/in.go index c2a6559..7ccf065 100644 --- a/cmd/clock/in.go +++ b/cmd/clock/in.go @@ -4,17 +4,21 @@ import ( "fmt" "time" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/spf13/cobra" ) -func inCMD(client *odoo.XMLRPCClient) *cobra.Command { +func inCMD(deps *app.Deps) *cobra.Command { InCmd := &cobra.Command{ Use: "in", Short: "Clock in (start attendance)", RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } - _, err := client.ClockIn() + _, err = client.ClockIn() if err != nil { return err } diff --git a/cmd/clock/out.go b/cmd/clock/out.go index 69ba8de..5dbf34e 100644 --- a/cmd/clock/out.go +++ b/cmd/clock/out.go @@ -4,16 +4,21 @@ import ( "fmt" "time" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/seletz/odoo-work-cli/internal/tui" "github.com/spf13/cobra" ) -func outCMD(client *odoo.XMLRPCClient) *cobra.Command { +func outCMD(deps *app.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "out", Short: "Clock out (end attendance)", RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + rec, err := client.ClockOut() if err != nil { return err diff --git a/cmd/clock/status.go b/cmd/clock/status.go index 493fdae..9230a5e 100644 --- a/cmd/clock/status.go +++ b/cmd/clock/status.go @@ -4,16 +4,21 @@ import ( "fmt" "time" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/seletz/odoo-work-cli/internal/tui" "github.com/spf13/cobra" ) -func statusCMD(client *odoo.XMLRPCClient) *cobra.Command { +func statusCMD(deps *app.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "status", Short: "Show current attendance status", RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + status, err := client.AttendanceStatus() if err != nil { return err diff --git a/cmd/config/config.go b/cmd/config/config.go index 5ee5edb..af2de66 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -11,7 +11,7 @@ import ( var configMerged bool -func CMD(cfgFile string) *cobra.Command { +func CMD(cfgFile *string) *cobra.Command { cmd := &cobra.Command{ Use: "config", @@ -20,7 +20,7 @@ func CMD(cfgFile string) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - result, err := config.Discover(cfgFile) + result, err := config.Discover(*cfgFile) if err != nil { return err } diff --git a/cmd/entries/add.go b/cmd/entries/add.go index 7f190d0..a7ea946 100644 --- a/cmd/entries/add.go +++ b/cmd/entries/add.go @@ -3,16 +3,21 @@ package entries import ( "fmt" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/spf13/cobra" ) -func addCmd(client *odoo.XMLRPCClient) *cobra.Command { +func addCmd(deps *app.Deps) *cobra.Command { ops := &subOps{} cmd := &cobra.Command{ Use: "add", Short: "Create a new timesheet entry", RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + params, err := buildTimesheetWriteParams(ops.projectID, ops.taskID, ops.date, diff --git a/cmd/entries/delete.go b/cmd/entries/delete.go index 007ec53..750abfb 100644 --- a/cmd/entries/delete.go +++ b/cmd/entries/delete.go @@ -3,16 +3,21 @@ package entries import ( "fmt" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/spf13/cobra" ) -func deleteCmd(client *odoo.XMLRPCClient) *cobra.Command { +func deleteCmd(deps *app.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "delete ID", Short: "Delete a timesheet entry", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + id, err := parseEntryID(args[0]) if err != nil { return err diff --git a/cmd/entries/entries.go b/cmd/entries/entries.go index 88c37c3..5adaefe 100644 --- a/cmd/entries/entries.go +++ b/cmd/entries/entries.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/seletz/odoo-work-cli/internal/filter" "github.com/seletz/odoo-work-cli/internal/odoo" "github.com/seletz/odoo-work-cli/internal/parsing" @@ -28,15 +29,19 @@ type subOps struct { description string } -func CMD(client *odoo.XMLRPCClient) *cobra.Command { +func CMD(deps *app.Deps) *cobra.Command { ops := &entrieOps{} cmd := &cobra.Command{ Use: "entries", Short: "List individual timesheet entries with full detail", RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + var dateFrom, dateTo string - var err error if ops.date != "" { dateFrom, dateTo, err = parsing.ParseDateRange(ops.date) } else { @@ -84,9 +89,9 @@ func CMD(client *odoo.XMLRPCClient) *cobra.Command { cmd.Flags().StringVar(&ops.status, "status", "", "filter by validation status (e.g. draft, validated)") // Set Subcommands - cmd.AddCommand(addCmd(client)) - cmd.AddCommand(updateCmd(client)) - cmd.AddCommand(deleteCmd(client)) + cmd.AddCommand(addCmd(deps)) + cmd.AddCommand(updateCmd(deps)) + cmd.AddCommand(deleteCmd(deps)) return cmd } diff --git a/cmd/entries/update.go b/cmd/entries/update.go index 1dd5311..567c8e0 100644 --- a/cmd/entries/update.go +++ b/cmd/entries/update.go @@ -3,17 +3,22 @@ package entries import ( "fmt" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/spf13/cobra" ) -func updateCmd(client *odoo.XMLRPCClient) *cobra.Command { +func updateCmd(deps *app.Deps) *cobra.Command { ops := &subOps{} cmd := &cobra.Command{ Use: "update ID", Short: "Update an existing timesheet entry", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + id, err := parseEntryID(args[0]) if err != nil { return err diff --git a/cmd/fields/fields.go b/cmd/fields/fields.go index 708608f..b5b46e5 100644 --- a/cmd/fields/fields.go +++ b/cmd/fields/fields.go @@ -3,16 +3,21 @@ package fields import ( "fmt" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/spf13/cobra" ) -func CMD(client *odoo.XMLRPCClient) *cobra.Command { +func CMD(deps *app.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "fields ", Short: "Inspect Odoo model fields", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + fields, err := client.GetFields(args[0]) if err != nil { return err diff --git a/cmd/odoo-work-cli/main.go b/cmd/odoo-work-cli/main.go index ab1bec5..23af2db 100644 --- a/cmd/odoo-work-cli/main.go +++ b/cmd/odoo-work-cli/main.go @@ -12,6 +12,7 @@ import ( "github.com/seletz/odoo-work-cli/cmd/tasks" "github.com/seletz/odoo-work-cli/cmd/timesheet" "github.com/seletz/odoo-work-cli/cmd/tui" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/seletz/odoo-work-cli/internal/config" "github.com/seletz/odoo-work-cli/internal/odoo" @@ -19,49 +20,49 @@ import ( "github.com/spf13/cobra" ) -var cfgFile string -var cfg *config.Config -var client *odoo.XMLRPCClient - func main() { + var cfgFile string + deps := &app.Deps{} rootCmd := &cobra.Command{ Use: "odoo-work-cli", Short: "CLI for managing Odoo 17 timesheets", Version: version.Version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - var err error - cfg, err = loadConfig() + cfg, err := loadConfig(cfgFile) if err != nil { fmt.Println(err) os.Exit(1) } - client, err = newClient(cfg) + client, err := newClient(cfg) if err != nil { fmt.Println(err) os.Exit(1) } - return err + deps.Config = cfg + deps.Client = client + return nil }, PersistentPostRun: func(cmd *cobra.Command, args []string) { - if client != nil { - client.Close() + if deps.Client != nil { + deps.Client.Close() + deps.Client = nil } }, } rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path (skip discovery)") - rootCmd.AddCommand(project.CMD(client, cfg)) - rootCmd.AddCommand(tasks.CMD(client)) - rootCmd.AddCommand(timesheet.CMD(client)) - rootCmd.AddCommand(fields.CMD(client)) - rootCmd.AddCommand(whoamiCmd) - rootCmd.AddCommand(configcmd.CMD(cfgFile)) - rootCmd.AddCommand(tui.CMD(client, cfg)) - rootCmd.AddCommand(entries.CMD(client)) - rootCmd.AddCommand(clock.CMD(client)) + rootCmd.AddCommand(project.CMD(deps)) + rootCmd.AddCommand(tasks.CMD(deps)) + rootCmd.AddCommand(timesheet.CMD(deps)) + rootCmd.AddCommand(fields.CMD(deps)) + rootCmd.AddCommand(whoamiCMD(deps)) + rootCmd.AddCommand(configcmd.CMD(&cfgFile)) + rootCmd.AddCommand(tui.CMD(deps)) + rootCmd.AddCommand(entries.CMD(deps)) + rootCmd.AddCommand(clock.CMD(deps)) if err := rootCmd.Execute(); err != nil { os.Exit(1) @@ -69,7 +70,7 @@ func main() { } // loadConfig loads and merges config using file discovery and env vars. -func loadConfig() (*config.Config, error) { +func loadConfig(cfgFile string) (*config.Config, error) { result, err := config.Discover(cfgFile) if err != nil { return nil, err @@ -85,28 +86,26 @@ func newClient(cfg *config.Config) (*odoo.XMLRPCClient, error) { return odoo.NewXMLRPCClient(cfg.URL, cfg.Database, cfg.Username, cfg.Password, cfg.WebPassword, cfg.TOTPSecret, cfg.Models) } -var whoamiCmd = &cobra.Command{ - Use: "whoami", - Short: "Show current Odoo user info", - RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - client, err := newClient(cfg) - if err != nil { - return err - } - defer client.Close() - info, err := client.WhoAmI() - if err != nil { - return err - } - fmt.Printf("ID: %d\n", info.ID) - fmt.Printf("Name: %s\n", info.Name) - fmt.Printf("Login: %s\n", info.Login) - fmt.Printf("Email: %s\n", info.Email) - fmt.Printf("Company: %s\n", info.Company) - return nil - }, +func whoamiCMD(deps *app.Deps) *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Show current Odoo user info", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + + info, err := client.WhoAmI() + if err != nil { + return err + } + fmt.Printf("ID: %d\n", info.ID) + fmt.Printf("Name: %s\n", info.Name) + fmt.Printf("Login: %s\n", info.Login) + fmt.Printf("Email: %s\n", info.Email) + fmt.Printf("Company: %s\n", info.Company) + return nil + }, + } } diff --git a/cmd/project/projects.go b/cmd/project/projects.go index 5bb61a6..8487e18 100644 --- a/cmd/project/projects.go +++ b/cmd/project/projects.go @@ -4,16 +4,24 @@ import ( "fmt" "strings" - "github.com/seletz/odoo-work-cli/internal/config" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/spf13/cobra" ) -func CMD(client *odoo.XMLRPCClient, cfg *config.Config) *cobra.Command { +func CMD(deps *app.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "projects", Short: "List Odoo projects", RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + cfg, err := deps.RequireConfig() + if err != nil { + return err + } + projects, err := client.ListProjects() if err != nil { return err diff --git a/cmd/tasks/tasks.go b/cmd/tasks/tasks.go index 759191e..295da22 100644 --- a/cmd/tasks/tasks.go +++ b/cmd/tasks/tasks.go @@ -4,17 +4,21 @@ import ( "fmt" "strconv" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/spf13/cobra" ) -func CMD(client *odoo.XMLRPCClient) *cobra.Command { +func CMD(deps *app.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "tasks [project-id]", Short: "List Odoo tasks", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - var err error + client, err := deps.RequireClient() + if err != nil { + return err + } + var projectID int64 if len(args) == 1 { projectID, err = strconv.ParseInt(args[0], 10, 64) diff --git a/cmd/timesheet/timesheet.go b/cmd/timesheet/timesheet.go index 2b949e0..6fc670a 100644 --- a/cmd/timesheet/timesheet.go +++ b/cmd/timesheet/timesheet.go @@ -3,18 +3,22 @@ package timesheet import ( "fmt" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/seletz/odoo-work-cli/internal/parsing" "github.com/spf13/cobra" ) var tsWeek string -func CMD(client *odoo.XMLRPCClient) *cobra.Command { +func CMD(deps *app.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "timesheets", Short: "List Odoo timesheets for a week", RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } dateFrom, dateTo, err := parsing.WeekDateRange(tsWeek) if err != nil { diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 8288b6f..8375a4d 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -2,19 +2,26 @@ package tui import ( tea "charm.land/bubbletea/v2" - "github.com/seletz/odoo-work-cli/internal/config" - "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/app" "github.com/seletz/odoo-work-cli/internal/tui" "github.com/spf13/cobra" ) var tuiWeek string -func CMD(client *odoo.XMLRPCClient, cfg *config.Config) *cobra.Command { +func CMD(deps *app.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "tui", Short: "Interactive weekly timesheet view", RunE: func(cmd *cobra.Command, args []string) error { + client, err := deps.RequireClient() + if err != nil { + return err + } + cfg, err := deps.RequireConfig() + if err != nil { + return err + } monday, err := tui.ParseWeekMonday(tuiWeek) if err != nil { From 022d70d97ad2105f97f1a41c01bec223ba212b28 Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 20:37:34 +0100 Subject: [PATCH 07/12] Add `isNoneSetupCommand` to skip config loading for specific commands and tests for verification. Remove unused `PersistentPreRunE` in the config command. --- cmd/config/config.go | 3 --- cmd/odoo-work-cli/main.go | 16 ++++++++++++++++ cmd/odoo-work-cli/main_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 cmd/odoo-work-cli/main_test.go diff --git a/cmd/config/config.go b/cmd/config/config.go index af2de66..6e46f0b 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -16,9 +16,6 @@ func CMD(cfgFile *string) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Show discovered config files and merged configuration", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - return nil - }, RunE: func(cmd *cobra.Command, args []string) error { result, err := config.Discover(*cfgFile) if err != nil { diff --git a/cmd/odoo-work-cli/main.go b/cmd/odoo-work-cli/main.go index 23af2db..3ddb46d 100644 --- a/cmd/odoo-work-cli/main.go +++ b/cmd/odoo-work-cli/main.go @@ -29,6 +29,10 @@ func main() { Short: "CLI for managing Odoo 17 timesheets", Version: version.Version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if isNoneSetupCommand(cmd) { + return nil + } + cfg, err := loadConfig(cfgFile) if err != nil { fmt.Println(err) @@ -69,6 +73,18 @@ func main() { } } +func isNoneSetupCommand(cmd *cobra.Command) bool { + for current := cmd; current != nil; current = current.Parent() { + switch current.Name() { + case "completion", "__complete", "__completeNoDesc": + return true + case "config": + return true + } + } + return false +} + // loadConfig loads and merges config using file discovery and env vars. func loadConfig(cfgFile string) (*config.Config, error) { result, err := config.Discover(cfgFile) diff --git a/cmd/odoo-work-cli/main_test.go b/cmd/odoo-work-cli/main_test.go new file mode 100644 index 0000000..152a2dc --- /dev/null +++ b/cmd/odoo-work-cli/main_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestIsCompletionCommand(t *testing.T) { + root := &cobra.Command{Use: "odoo-work-cli"} + completion := &cobra.Command{Use: "completion"} + zsh := &cobra.Command{Use: "zsh"} + tasks := &cobra.Command{Use: "tasks"} + internalComplete := &cobra.Command{Use: "__complete"} + + root.AddCommand(completion, tasks, internalComplete) + completion.AddCommand(zsh) + + if !isNoneSetupCommand(completion) { + t.Fatal("expected completion command to be detected") + } + + if !isNoneSetupCommand(zsh) { + t.Fatal("expected completion subcommand to be detected") + } + + if !isNoneSetupCommand(internalComplete) { + t.Fatal("expected internal completion command to be detected") + } + + if isNoneSetupCommand(tasks) { + t.Fatal("did not expect regular command to be detected as completion") + } +} From e229b2a8e86c7130aff1d8e1ad9b7413e12158f7 Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 20:58:57 +0100 Subject: [PATCH 08/12] Add support for company prefixes in grid rows and search labels, ensuring unique identification for rows with the same project and task names across different companies. Refactor logic for row and hint key generation, improve sorting, and update tests for validation. --- internal/odoo/client.go | 1 + internal/odoo/xmlrpc.go | 31 ++++++---- internal/tui/grid.go | 109 ++++++++++++++++++++++++++++-------- internal/tui/grid_test.go | 44 +++++++++++++++ internal/tui/model.go | 52 ++++++++++++----- internal/tui/model_test.go | 44 ++++++++------- internal/tui/render.go | 15 +++-- internal/tui/render_test.go | 15 ++++- 8 files changed, 240 insertions(+), 71 deletions(-) diff --git a/internal/odoo/client.go b/internal/odoo/client.go index 9582f52..4ddf5af 100644 --- a/internal/odoo/client.go +++ b/internal/odoo/client.go @@ -29,6 +29,7 @@ type TaskInfo struct { Name string Project string ProjectID int64 + Company string Stage string Active bool } diff --git a/internal/odoo/xmlrpc.go b/internal/odoo/xmlrpc.go index cd2689f..638f159 100644 --- a/internal/odoo/xmlrpc.go +++ b/internal/odoo/xmlrpc.go @@ -276,7 +276,11 @@ func (x *XMLRPCClient) listTasks(projectID int64, filtered bool) ([]TaskInfo, er if projectID > 0 { criteria.Add("project_id", "=", projectID) } - tasks, err := x.client.FindProjectTasks(criteria, goOdoo.NewOptions()) + + fields := []string{"id", "name", "active", "project_id", "stage_id", "company_id"} + opts := goOdoo.NewOptions().FetchFields(fields...) + + records, err := x.searchReadRaw("project.task", criteria, opts) if IsNotFound(err) { return []TaskInfo{}, nil } @@ -284,19 +288,24 @@ func (x *XMLRPCClient) listTasks(projectID int64, filtered bool) ([]TaskInfo, er return nil, fmt.Errorf("fetching tasks: %w", err) } - result := make([]TaskInfo, 0, len(*tasks)) - for _, t := range *tasks { + result := make([]TaskInfo, 0, len(records)) + for _, r := range records { info := TaskInfo{ - ID: t.Id.Get(), - Name: t.Name.Get(), - Active: t.Active.Get(), + Project: extractMany2OneName(r["project_id"]), + ProjectID: extractMany2OneID(r["project_id"]), + Company: extractMany2OneName(r["company_id"]), + Stage: extractMany2OneName(r["stage_id"]), + } + if id, ok := r["id"].(int64); ok { + info.ID = id + } else if id, ok := r["id"].(float64); ok { + info.ID = int64(id) } - if t.ProjectId != nil { - info.Project = t.ProjectId.Name - info.ProjectID = t.ProjectId.ID + if name, ok := r["name"].(string); ok { + info.Name = name } - if t.StageId != nil { - info.Stage = t.StageId.Name + if active, ok := r["active"].(bool); ok { + info.Active = active } result = append(result, info) } diff --git a/internal/tui/grid.go b/internal/tui/grid.go index 06daa01..9d4e459 100644 --- a/internal/tui/grid.go +++ b/internal/tui/grid.go @@ -7,12 +7,14 @@ import ( "strconv" "strings" "time" + "unicode" "github.com/seletz/odoo-work-cli/internal/odoo" ) // GridRow represents a single project/task row in the weekly grid. type GridRow struct { + Key string Label string Company string // company name from first entry Hours [7]float64 // Mon=0 .. Sun=6 @@ -56,22 +58,23 @@ func BuildWeekGrid(entries []odoo.TimesheetEntry, monday time.Time) WeekGrid { continue } - label := e.Project - if e.Task != "" { - label += " / " + e.Task - } + key := gridRowKey(e.Company, e.ProjectID, e.TaskID, e.Project, e.Task) + label := gridRowLabel(e.Company, e.Project, e.Task) - idx, ok := rowIndex[label] + idx, ok := rowIndex[key] if !ok { idx = len(g.Rows) - rowIndex[label] = idx - g.Rows = append(g.Rows, GridRow{Label: label, Company: e.Company}) + rowIndex[key] = idx + g.Rows = append(g.Rows, GridRow{Key: key, Label: label, Company: e.Company}) } g.Rows[idx].Hours[dayOffset] += e.Hours g.Rows[idx].Entries[dayOffset] = append(g.Rows[idx].Entries[dayOffset], e) } sort.Slice(g.Rows, func(i, j int) bool { + if g.Rows[i].Label == g.Rows[j].Label { + return g.Rows[i].Key < g.Rows[j].Key + } return g.Rows[i].Label < g.Rows[j].Label }) @@ -87,6 +90,7 @@ func BuildWeekGrid(entries []odoo.TimesheetEntry, monday time.Time) WeekGrid { // HintRow carries label and IDs from a previous week's entries to seed empty rows. type HintRow struct { + Key string Label string Company string ProjectID int64 @@ -98,16 +102,14 @@ func HintLabelsFromEntries(entries []odoo.TimesheetEntry) []HintRow { seen := make(map[string]bool) var hints []HintRow for _, e := range entries { - label := e.Project - if e.Task != "" { - label += " / " + e.Task - } - if seen[label] { + key := gridRowKey(e.Company, e.ProjectID, e.TaskID, e.Project, e.Task) + if seen[key] { continue } - seen[label] = true + seen[key] = true hints = append(hints, HintRow{ - Label: label, + Key: key, + Label: gridRowLabel(e.Company, e.Project, e.Task), Company: e.Company, ProjectID: e.ProjectID, TaskID: e.TaskID, @@ -132,16 +134,14 @@ func BuildWeekGridWithHints(entries []odoo.TimesheetEntry, monday time.Time, hin continue } - label := e.Project - if e.Task != "" { - label += " / " + e.Task - } + key := gridRowKey(e.Company, e.ProjectID, e.TaskID, e.Project, e.Task) + label := gridRowLabel(e.Company, e.Project, e.Task) - idx, ok := rowIndex[label] + idx, ok := rowIndex[key] if !ok { idx = len(g.Rows) - rowIndex[label] = idx - g.Rows = append(g.Rows, GridRow{Label: label, Company: e.Company}) + rowIndex[key] = idx + g.Rows = append(g.Rows, GridRow{Key: key, Label: label, Company: e.Company}) } g.Rows[idx].Hours[dayOffset] += e.Hours g.Rows[idx].Entries[dayOffset] = append(g.Rows[idx].Entries[dayOffset], e) @@ -149,12 +149,14 @@ func BuildWeekGridWithHints(entries []odoo.TimesheetEntry, monday time.Time, hin // Add hint rows for labels not already present. for _, h := range hints { - if _, ok := rowIndex[h.Label]; ok { + hintKey := hintIdentity(h) + if _, ok := rowIndex[hintKey]; ok { continue } idx := len(g.Rows) - rowIndex[h.Label] = idx + rowIndex[hintKey] = idx g.Rows = append(g.Rows, GridRow{ + Key: hintKey, Label: h.Label, Company: h.Company, HintProjectID: h.ProjectID, @@ -163,6 +165,9 @@ func BuildWeekGridWithHints(entries []odoo.TimesheetEntry, monday time.Time, hin } sort.Slice(g.Rows, func(i, j int) bool { + if g.Rows[i].Label == g.Rows[j].Label { + return g.Rows[i].Key < g.Rows[j].Key + } return g.Rows[i].Label < g.Rows[j].Label }) @@ -176,6 +181,64 @@ func BuildWeekGridWithHints(entries []odoo.TimesheetEntry, monday time.Time, hin return g } +func gridRowKey(company string, projectID, taskID int64, project, task string) string { + if projectID != 0 || taskID != 0 { + return fmt.Sprintf("%s|%d|%d", company, projectID, taskID) + } + return fmt.Sprintf("%s|%s|%s", company, project, task) +} + +func gridRowLabel(company, project, task string) string { + label := project + if task != "" { + label += " / " + task + } + prefix := companyPrefix(company) + if prefix == "" { + return label + } + return fmt.Sprintf("[%s] %s", prefix, label) +} + +func companyPrefix(company string) string { + company = strings.TrimSpace(company) + if company == "" { + return "" + } + + var runes []rune + for _, r := range company { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + runes = append(runes, unicode.ToUpper(r)) + if len(runes) == 3 { + return string(runes) + } + } + } + if len(runes) > 0 { + return string(runes) + } + + raw := []rune(company) + if len(raw) > 3 { + raw = raw[:3] + } + for i, r := range raw { + raw[i] = unicode.ToUpper(r) + } + return string(raw) +} + +func hintIdentity(h HintRow) string { + if h.Key != "" { + return h.Key + } + if h.ProjectID != 0 || h.TaskID != 0 { + return gridRowKey(h.Company, h.ProjectID, h.TaskID, "", "") + } + return h.Label +} + // ParseWeekMonday parses an ISO week string (e.g. "2026-W10") and returns // the Monday of that week. If week is empty, returns the Monday of the // current week. diff --git a/internal/tui/grid_test.go b/internal/tui/grid_test.go index 3d35354..46fe9a2 100644 --- a/internal/tui/grid_test.go +++ b/internal/tui/grid_test.go @@ -113,6 +113,31 @@ func TestBuildWeekGrid_WeekendEntries(t *testing.T) { } } +func TestBuildWeekGrid_SameNamesDifferentCompaniesStaySeparate(t *testing.T) { + entries := []odoo.TimesheetEntry{ + {Date: "2026-03-02", Project: "Shared", Task: "Dev", Company: "Alpha Org", ProjectID: 10, TaskID: 20, Hours: 2.0}, + {Date: "2026-03-02", Project: "Shared", Task: "Dev", Company: "Beta Org", ProjectID: 30, TaskID: 40, Hours: 3.0}, + } + + g := BuildWeekGrid(entries, monday(2026, 3, 2)) + + if len(g.Rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(g.Rows)) + } + if g.Rows[0].Label != "[ALP] Shared / Dev" { + t.Fatalf("expected first prefixed row, got %q", g.Rows[0].Label) + } + if g.Rows[1].Label != "[BET] Shared / Dev" { + t.Fatalf("expected second prefixed row, got %q", g.Rows[1].Label) + } + if g.Rows[0].Hours[0] != 2.0 { + t.Fatalf("expected first row Mon=2.0, got %f", g.Rows[0].Hours[0]) + } + if g.Rows[1].Hours[0] != 3.0 { + t.Fatalf("expected second row Mon=3.0, got %f", g.Rows[1].Hours[0]) + } +} + func TestBuildWeekGrid_PreservesEntries(t *testing.T) { entries := []odoo.TimesheetEntry{ {ID: 100, Date: "2026-03-03", Project: "Acme", Task: "Dev", Hours: 1.0, Name: "auth endpoint", ValidatedStatus: "draft"}, @@ -203,6 +228,25 @@ func TestHintLabelsFromEntries_EmptyTask(t *testing.T) { } } +func TestHintLabelsFromEntries_SameNamesDifferentCompanies(t *testing.T) { + entries := []odoo.TimesheetEntry{ + {Date: "2026-02-23", Project: "Shared", Task: "Dev", Company: "Alpha Org", ProjectID: 10, TaskID: 20, Hours: 1.0}, + {Date: "2026-02-24", Project: "Shared", Task: "Dev", Company: "Beta Org", ProjectID: 30, TaskID: 40, Hours: 1.0}, + } + + hints := HintLabelsFromEntries(entries) + + if len(hints) != 2 { + t.Fatalf("expected 2 hints, got %d", len(hints)) + } + if hints[0].Label != "[ALP] Shared / Dev" { + t.Fatalf("expected first prefixed hint, got %q", hints[0].Label) + } + if hints[1].Label != "[BET] Shared / Dev" { + t.Fatalf("expected second prefixed hint, got %q", hints[1].Label) + } +} + func TestBuildWeekGridWithHints_EmptyWeek(t *testing.T) { hints := []HintRow{ {Label: "Acme / Dev", ProjectID: 10, TaskID: 20}, diff --git a/internal/tui/model.go b/internal/tui/model.go index 6d090ed..4296521 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -41,8 +41,10 @@ type searchItem struct { ID int64 Name string Extra string // company for projects, project name for tasks - ProjectID int64 // for tasks: the parent project ID - TaskID int64 // 0 for projects, task ID for tasks + Company string + Project string + ProjectID int64 // for tasks: the parent project ID + TaskID int64 // 0 for projects, task ID for tasks } // timesheetsLoadedMsg is sent when timesheets finish loading. @@ -243,11 +245,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // and prune those that now appear in the grid naturally. gridLabels := make(map[string]bool, len(m.grid.Rows)) for _, row := range m.grid.Rows { - gridLabels[row.Label] = true + gridLabels[rowIdentity(row)] = true } var remaining []GridRow for _, pr := range m.pendingRows { - if gridLabels[pr.Label] { + if gridLabels[rowIdentity(pr)] { continue // server now has entries for this row } m.grid.Rows = append(m.grid.Rows, pr) @@ -256,6 +258,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pendingRows = remaining if len(remaining) > 0 { sort.Slice(m.grid.Rows, func(i, j int) bool { + if m.grid.Rows[i].Label == m.grid.Rows[j].Label { + return m.grid.Rows[i].Key < m.grid.Rows[j].Key + } return m.grid.Rows[i].Label < m.grid.Rows[j].Label }) } @@ -621,6 +626,7 @@ func (m Model) deleteEntry() (tea.Model, tea.Cmd) { if totalEntries <= 1 { projectID, taskID := row.ProjectTaskIDs() m.pendingRows = append(m.pendingRows, GridRow{ + Key: rowIdentity(row), Label: row.Label, Company: row.Company, HintProjectID: projectID, @@ -703,6 +709,8 @@ func buildSearchItems(projects []odoo.ProjectInfo, tasks []odoo.TaskInfo) []sear ID: p.ID, Name: p.Name, Extra: p.Company, + Company: p.Company, + Project: p.Name, ProjectID: p.ID, TaskID: 0, }) @@ -713,6 +721,8 @@ func buildSearchItems(projects []odoo.ProjectInfo, tasks []odoo.TaskInfo) []sear ID: t.ID, Name: t.Name, Extra: t.Project, + Company: t.Company, + Project: t.Project, ProjectID: t.ProjectID, TaskID: t.ID, }) @@ -729,7 +739,8 @@ func filterSearchItems(items []searchItem, query string) []searchItem { var result []searchItem for _, item := range items { if strings.Contains(strings.ToLower(item.Name), q) || - strings.Contains(strings.ToLower(item.Extra), q) { + strings.Contains(strings.ToLower(item.Extra), q) || + strings.Contains(strings.ToLower(item.Company), q) { result = append(result, item) } } @@ -791,16 +802,12 @@ func (m Model) updateSearch(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // selectSearchItem adds the selected project/task as a grid row or moves cursor to existing. func (m Model) selectSearchItem(item searchItem) (tea.Model, tea.Cmd) { - var label string - if item.Kind == "project" { - label = item.Name - } else { - label = item.Extra + " / " + item.Name - } + label := gridRowLabel(item.Company, item.Project, taskNameForItem(item)) + key := gridRowKey(item.Company, item.ProjectID, item.TaskID, item.Project, taskNameForItem(item)) // Check for duplicate label. for i, row := range m.grid.Rows { - if row.Label == label { + if row.Key == key { m.cursor[0] = i m.state = stateGrid return m, nil @@ -809,7 +816,9 @@ func (m Model) selectSearchItem(item searchItem) (tea.Model, tea.Cmd) { // Add new row and track as pending so it survives reloads. newRow := GridRow{ + Key: key, Label: label, + Company: item.Company, HintProjectID: item.ProjectID, HintTaskID: item.TaskID, } @@ -818,10 +827,13 @@ func (m Model) selectSearchItem(item searchItem) (tea.Model, tea.Cmd) { // Re-sort and find the new row's index. sort.Slice(m.grid.Rows, func(i, j int) bool { + if m.grid.Rows[i].Label == m.grid.Rows[j].Label { + return m.grid.Rows[i].Key < m.grid.Rows[j].Key + } return m.grid.Rows[i].Label < m.grid.Rows[j].Label }) for i, row := range m.grid.Rows { - if row.Label == label { + if row.Key == key { m.cursor[0] = i break } @@ -831,6 +843,20 @@ func (m Model) selectSearchItem(item searchItem) (tea.Model, tea.Cmd) { return m, nil } +func taskNameForItem(item searchItem) string { + if item.Kind != "task" { + return "" + } + return item.Name +} + +func rowIdentity(row GridRow) string { + if row.Key != "" { + return row.Key + } + return row.Label +} + // formatDecimalHours formats hours as a decimal string (e.g. "2.5"). func formatDecimalHours(h float64) string { return strconv.FormatFloat(h, 'f', -1, 64) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index ad553d2..671dd4f 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1074,7 +1074,7 @@ func newSearchModel(projects []odoo.ProjectInfo, tasks []odoo.TaskInfo) Model { projects: projects, tasks: tasks, entries: []odoo.TimesheetEntry{ - {ID: 1, Date: "2026-03-02", Project: "Acme", Task: "Dev", Hours: 2.0, ProjectID: 10, TaskID: 20}, + {ID: 1, Date: "2026-03-02", Project: "Acme", Task: "Dev", Company: "Acme Org", Hours: 2.0, ProjectID: 10, TaskID: 20}, }, } mon := MondayTime{Time: time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)} @@ -1120,8 +1120,8 @@ func TestModel_SearchDataLoaded(t *testing.T) { {ID: 2, Name: "Beta", Company: "Corp B"}, }, tasks: []odoo.TaskInfo{ - {ID: 10, Name: "Task X", Project: "Alpha", ProjectID: 1}, - {ID: 11, Name: "Task Y", Project: "Beta", ProjectID: 2}, + {ID: 10, Name: "Task X", Project: "Alpha", ProjectID: 1, Company: "Corp A"}, + {ID: 11, Name: "Task Y", Project: "Beta", ProjectID: 2, Company: "Corp B"}, }, } updated, _ = um.Update(msg) @@ -1174,7 +1174,7 @@ func TestModel_SearchFilter(t *testing.T) { {ID: 2, Name: "Beta", Company: "Corp B"}, }, tasks: []odoo.TaskInfo{ - {ID: 10, Name: "Task Alpha", Project: "Alpha", ProjectID: 1}, + {ID: 10, Name: "Task Alpha", Project: "Alpha", ProjectID: 1, Company: "Corp A"}, }, } updated, _ = um.Update(msg) @@ -1308,7 +1308,7 @@ func TestModel_SearchSelectProject(t *testing.T) { // Should have added a new row. found := false for _, row := range um.grid.Rows { - if row.Label == "NewProject" { + if row.Label == "[ACM] NewProject" { found = true if row.HintProjectID != 5 { t.Fatalf("expected HintProjectID=5, got %d", row.HintProjectID) @@ -1319,21 +1319,21 @@ func TestModel_SearchSelectProject(t *testing.T) { } } if !found { - t.Fatal("expected 'NewProject' row in grid") + t.Fatal("expected '[ACM] NewProject' row in grid") } } func TestModel_SearchSelectTask(t *testing.T) { m := newSearchModel( nil, - []odoo.TaskInfo{{ID: 42, Name: "TaskZ", Project: "ProjX", ProjectID: 7}}, + []odoo.TaskInfo{{ID: 42, Name: "TaskZ", Project: "ProjX", ProjectID: 7, Company: "Acme"}}, ) updated, _ := m.Update(tea.KeyPressMsg{Code: '/'}) um := updated.(Model) // Load data. updated, _ = um.Update(searchDataLoadedMsg{ - tasks: []odoo.TaskInfo{{ID: 42, Name: "TaskZ", Project: "ProjX", ProjectID: 7}}, + tasks: []odoo.TaskInfo{{ID: 42, Name: "TaskZ", Project: "ProjX", ProjectID: 7, Company: "Acme"}}, }) um = updated.(Model) @@ -1346,7 +1346,7 @@ func TestModel_SearchSelectTask(t *testing.T) { } found := false for _, row := range um.grid.Rows { - if row.Label == "ProjX / TaskZ" { + if row.Label == "[ACM] ProjX / TaskZ" { found = true if row.HintProjectID != 7 { t.Fatalf("expected HintProjectID=7, got %d", row.HintProjectID) @@ -1357,19 +1357,19 @@ func TestModel_SearchSelectTask(t *testing.T) { } } if !found { - t.Fatal("expected 'ProjX / TaskZ' row in grid") + t.Fatal("expected '[ACM] ProjX / TaskZ' row in grid") } } func TestModel_SearchDuplicateRow(t *testing.T) { - // "Acme / Dev" already exists from entries. + // "[ACM] Acme / Dev" already exists from entries. m := newSearchModel(nil, nil) updated, _ := m.Update(tea.KeyPressMsg{Code: '/'}) um := updated.(Model) // Load data with a task that matches existing row label. updated, _ = um.Update(searchDataLoadedMsg{ - tasks: []odoo.TaskInfo{{ID: 20, Name: "Dev", Project: "Acme", ProjectID: 10}}, + tasks: []odoo.TaskInfo{{ID: 20, Name: "Dev", Project: "Acme", ProjectID: 10, Company: "Acme Org"}}, }) um = updated.(Model) @@ -1392,8 +1392,8 @@ func TestRenderSearchOverlay(t *testing.T) { input.SetValue("test") items := []searchItem{ - {Kind: "project", Name: "Alpha", Extra: "Corp"}, - {Kind: "task", Name: "Task X", Extra: "Alpha"}, + {Kind: "project", Name: "Alpha", Extra: "Corp", Company: "Corp"}, + {Kind: "task", Name: "Task X", Extra: "Alpha", Company: "Corp"}, } result := renderSearchOverlay(input, items, 0, searchReady, true, nil, spinner.New(), 80, 40, nil) @@ -1413,6 +1413,12 @@ func TestRenderSearchOverlay(t *testing.T) { if !strings.Contains(result, "Task X") { t.Fatal("expected 'Task X' in output") } + if !strings.Contains(result, "[COR] Alpha") { + t.Fatal("expected '[COR] Alpha' in output") + } + if !strings.Contains(result, "[COR] Task X") { + t.Fatal("expected '[COR] Task X' in output") + } } func TestRenderSearchOverlay_Unfiltered(t *testing.T) { @@ -1878,18 +1884,18 @@ func TestModel_SearchAddedRowSurvivesReload(t *testing.T) { // Verify the row was added. foundBefore := false for _, row := range um.grid.Rows { - if row.Label == "BrandNew" { + if row.Label == "[COR] BrandNew" { foundBefore = true } } if !foundBefore { - t.Fatal("expected 'BrandNew' row in grid before reload") + t.Fatal("expected '[COR] BrandNew' row in grid before reload") } // Press Enter on the new row to go to detail → triggers timesheet reload. // First move cursor to the "BrandNew" row. for i, row := range um.grid.Rows { - if row.Label == "BrandNew" { + if row.Label == "[COR] BrandNew" { um.cursor[0] = i break } @@ -1920,7 +1926,7 @@ func TestModel_SearchAddedRowSurvivesReload(t *testing.T) { // The "BrandNew" row must still be in the grid. found := false for _, row := range um.grid.Rows { - if row.Label == "BrandNew" { + if row.Label == "[COR] BrandNew" { found = true if row.HintProjectID != 99 { t.Fatalf("expected HintProjectID=99, got %d", row.HintProjectID) @@ -1928,7 +1934,7 @@ func TestModel_SearchAddedRowSurvivesReload(t *testing.T) { } } if !found { - t.Fatal("BUG #30: search-added row 'BrandNew' vanished after timesheet reload") + t.Fatal("BUG #30: search-added row '[COR] BrandNew' vanished after timesheet reload") } } diff --git a/internal/tui/render.go b/internal/tui/render.go index 713d2d6..500a578 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -153,8 +153,8 @@ func RenderGrid(grid WeekGrid, cursorRow, cursorCol, width int, limits config.Ho var b strings.Builder - // Header row with vertical separators. - header := fmt.Sprintf("%-*s ", labelWidth, "Project / Task") + // Header row. + header := fmt.Sprintf("%-*s ", labelWidth, "Company Prefix / Project / Task") for i, name := range dayNames { cell := fmt.Sprintf("%*s", colWidth-1, name) switch { @@ -556,14 +556,21 @@ func renderSearchOverlay(input textinput.Model, items []searchItem, cursor int, lastKind = item.Kind } - // Colored badge for kind. + // Colored badge for kind plus optional company prefix. var badge string if item.Kind == "project" { badge = searchProjectBadge.Render("[P]") } else { badge = searchTaskBadge.Render("[T]") } - label := fmt.Sprintf(" %s %s", badge, item.Name) + labelPrefix := "" + if prefix := companyPrefix(item.Company); prefix != "" { + labelPrefix = "[" + prefix + "] " + if color, ok := companyColors[item.Company]; ok { + labelPrefix = companyLabelStyle(color).Render(labelPrefix) + } + } + label := fmt.Sprintf(" %s %s%s", badge, labelPrefix, item.Name) if item.Extra != "" { extra := item.Extra if item.Kind == "project" { diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go index 5e6e5d0..46752a6 100644 --- a/internal/tui/render_test.go +++ b/internal/tui/render_test.go @@ -30,11 +30,24 @@ func TestRenderGrid_ContainsLabels(t *testing.T) { } } +func TestRenderGrid_ShowsCompanyPrefix(t *testing.T) { + entries := []odoo.TimesheetEntry{ + {Date: "2026-03-02", Project: "Acme", Task: "Dev", Company: "Digital Team", Hours: 8.0}, + } + + g := BuildWeekGrid(entries, monday(2026, 3, 2)) + out := RenderGrid(g, 0, 0, 120, config.DefaultHoursLimits(), [7]string{}, nil) + + if !strings.Contains(out, "[DIG] Acme / Dev") { + t.Error("output should contain prefixed company label '[DIG] Acme / Dev'") + } +} + func TestRenderGrid_EmptyGrid(t *testing.T) { g := BuildWeekGrid(nil, monday(2026, 3, 2)) out := RenderGrid(g, 0, 0, 120, config.DefaultHoursLimits(), [7]string{}, nil, -1) - if !strings.Contains(out, "Project / Task") { + if !strings.Contains(out, "Company Prefix / Project / Task") { t.Error("output should contain header") } if !strings.Contains(out, "Total") { From e2b7c1a15782921028fd28e0523d912ee973ebe3 Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 21:03:53 +0100 Subject: [PATCH 09/12] Wrap grid row labels instead of truncating with ellipsis. Add helper functions for label wrapping and improve the rendering logic to ensure proper alignment. Add tests to validate label wrapping behavior. --- internal/tui/render.go | 125 ++++++++++++++++++++++++++++-------- internal/tui/render_test.go | 22 +++++++ 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/internal/tui/render.go b/internal/tui/render.go index 500a578..3d42787 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -203,37 +203,50 @@ func RenderGrid(grid WeekGrid, cursorRow, cursorCol, width int, limits config.Ho // Data rows. for ri, row := range grid.Rows { - label := row.Label - if len(label) > labelWidth { - label = label[:labelWidth-1] + "…" - } - styledLabel := fmt.Sprintf("%-*s", labelWidth, label) - if color, ok := companyColors[row.Company]; ok { - styledLabel = companyLabelStyle(color).Render(styledLabel) - } - line := styledLabel + " " - + labelLines := wrapLabel(row.Label, labelWidth) + var rowLines []string var rowTotal float64 for d := 0; d < 7; d++ { rowTotal += row.Hours[d] - cell := fmt.Sprintf("%*s", colWidth-1, FormatHours(row.Hours[d])) - switch { - case ri == cursorRow && d == cursorCol: - cell = cursorStyle.Render(cell) - case isHoliday(d): - cell = holidayStyle.Render(cell) - case d >= 5: - cell = weekendStyle.Render(cell) - case row.Hours[d] > 0: - cell = hoursBgStyle(grid.DayTotals[d], limits).Render(cell) - case d == todayCol: - cell = todayCellStyle.Render(cell) + } + + for li, label := range labelLines { + styledLabel := fmt.Sprintf("%-*s", labelWidth, label) + if color, ok := companyColors[row.Company]; ok { + styledLabel = companyLabelStyle(color).Render(styledLabel) + } + line := styledLabel + " " + + for d := 0; d < 7; d++ { + cell := fmt.Sprintf("%*s", colWidth-1, "") + if li == 0 { + cell = fmt.Sprintf("%*s", colWidth-1, FormatHours(row.Hours[d])) + switch { + case ri == cursorRow && d == cursorCol: + cell = cursorStyle.Render(cell) + case isHoliday(d): + cell = holidayStyle.Render(cell) + case d >= 5: + cell = weekendStyle.Render(cell) + case row.Hours[d] > 0: + cell = hoursBgStyle(grid.DayTotals[d], limits).Render(cell) + case d == todayCol: + cell = todayCellStyle.Render(cell) + } + } + line += gridSepStyle.Render(boxV) + cell } - line += gridSepStyle.Render(boxV) + cell + + totalCell := fmt.Sprintf("%*s", colWidth-1, "") + if li == 0 { + totalCell = fmt.Sprintf("%*s", colWidth-1, FormatHours(rowTotal)) + totalCell = totalsStyle.Render(totalCell) + } + line += gridSepStyle.Render(boxV) + totalCell + rowLines = append(rowLines, line) } - totalCell := fmt.Sprintf("%*s", colWidth-1, FormatHours(rowTotal)) - line += gridSepStyle.Render(boxV) + totalsStyle.Render(totalCell) - b.WriteString(line) + + b.WriteString(strings.Join(rowLines, "\n")) b.WriteString("\n") } @@ -261,6 +274,66 @@ func RenderGrid(grid WeekGrid, cursorRow, cursorCol, width int, limits config.Ho return b.String() } +func wrapLabel(label string, width int) []string { + if width <= 0 { + return []string{label} + } + if runeLen(label) <= width { + return []string{label} + } + + words := strings.Fields(label) + if len(words) == 0 { + return []string{""} + } + + lines := make([]string, 0, 2) + current := "" + for _, word := range words { + if runeLen(word) > width { + if current != "" { + lines = append(lines, current) + current = "" + } + lines = append(lines, breakLongWord(word, width)...) + continue + } + + next := word + if current != "" { + next = current + " " + word + } + if runeLen(next) <= width { + current = next + continue + } + + lines = append(lines, current) + current = word + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func breakLongWord(word string, width int) []string { + runes := []rune(word) + lines := make([]string, 0, (len(runes)+width-1)/width) + for len(runes) > width { + lines = append(lines, string(runes[:width])) + runes = runes[width:] + } + if len(runes) > 0 { + lines = append(lines, string(runes)) + } + return lines +} + +func runeLen(s string) int { + return len([]rune(s)) +} + // RenderDetail renders a detail panel for a specific cell showing individual entries. // Uses the bubbles table widget for the entries listing. The returned string is the // inner content (no border); use RenderDetailOverlay to composite it as a centered diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go index 46752a6..a80e0ee 100644 --- a/internal/tui/render_test.go +++ b/internal/tui/render_test.go @@ -151,3 +151,25 @@ func TestRenderGrid_CorrectLineCount(t *testing.T) { t.Errorf("expected 7 lines, got %d", len(lines)) } } + +func TestRenderGrid_WrapsLongLabels(t *testing.T) { + entries := []odoo.TimesheetEntry{ + {Date: "2026-03-02", Project: "Infrastruktur und Betrieb", Task: "Infrastructure Management", Company: "Digital", Hours: 8.0}, + } + + g := BuildWeekGrid(entries, monday(2026, 3, 2)) + out := RenderGrid(g, 0, 0, 90, config.DefaultHoursLimits(), [7]string{}, nil) + + if strings.Contains(out, "…") { + t.Fatal("output should wrap long labels instead of truncating with ellipsis") + } + if !strings.Contains(out, "[DIG] Infrastruktur") { + t.Fatal("output should contain first wrapped label line") + } + if !strings.Contains(out, "Betrieb /") { + t.Fatal("output should contain continuation line for wrapped label") + } + if !strings.Contains(out, "8:00") { + t.Fatal("output should still contain hours for wrapped row") + } +} From dfa3c7e230c8eda4ac6bf30f513719849b2f270f Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Thu, 12 Mar 2026 21:12:58 +0100 Subject: [PATCH 10/12] Highlight the entire selected row in the grid, including labels, cells, and totals. Add tests to verify row highlighting behavior. --- internal/tui/render.go | 10 ++++++++++ internal/tui/render_test.go | 25 +++++++++++++++++++++++++ internal/tui/styles.go | 7 ++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/internal/tui/render.go b/internal/tui/render.go index 3d42787..526e4cc 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -203,6 +203,7 @@ func RenderGrid(grid WeekGrid, cursorRow, cursorCol, width int, limits config.Ho // Data rows. for ri, row := range grid.Rows { + rowSelected := ri == cursorRow labelLines := wrapLabel(row.Label, labelWidth) var rowLines []string var rowTotal float64 @@ -215,6 +216,9 @@ func RenderGrid(grid WeekGrid, cursorRow, cursorCol, width int, limits config.Ho if color, ok := companyColors[row.Company]; ok { styledLabel = companyLabelStyle(color).Render(styledLabel) } + if rowSelected { + styledLabel = rowCursorStyle.Render(styledLabel) + } line := styledLabel + " " for d := 0; d < 7; d++ { @@ -234,6 +238,9 @@ func RenderGrid(grid WeekGrid, cursorRow, cursorCol, width int, limits config.Ho cell = todayCellStyle.Render(cell) } } + if rowSelected && !(li == 0 && d == cursorCol) { + cell = rowCursorStyle.Render(cell) + } line += gridSepStyle.Render(boxV) + cell } @@ -242,6 +249,9 @@ func RenderGrid(grid WeekGrid, cursorRow, cursorCol, width int, limits config.Ho totalCell = fmt.Sprintf("%*s", colWidth-1, FormatHours(rowTotal)) totalCell = totalsStyle.Render(totalCell) } + if rowSelected { + totalCell = rowCursorStyle.Render(totalCell) + } line += gridSepStyle.Render(boxV) + totalCell rowLines = append(rowLines, line) } diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go index a80e0ee..c8c1da3 100644 --- a/internal/tui/render_test.go +++ b/internal/tui/render_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "testing" @@ -173,3 +174,27 @@ func TestRenderGrid_WrapsLongLabels(t *testing.T) { t.Fatal("output should still contain hours for wrapped row") } } + +func TestRenderGrid_HighlightsWholeSelectedRow(t *testing.T) { + entries := []odoo.TimesheetEntry{ + {Date: "2026-03-02", Project: "Alpha", Task: "Dev", Hours: 1.0}, + {Date: "2026-03-02", Project: "Beta", Task: "QA", Hours: 2.0}, + } + + g := BuildWeekGrid(entries, monday(2026, 3, 2)) + out := RenderGrid(g, 1, 0, 120, config.DefaultHoursLimits(), [7]string{}, nil) + + selectedLabel := rowCursorStyle.Render(fmt.Sprintf("%-*s", 40, "Beta / QA")) + selectedCell := cursorStyle.Render(fmt.Sprintf("%*s", 9, "2:00")) + selectedTotal := rowCursorStyle.Render(totalsStyle.Render(fmt.Sprintf("%*s", 9, "2:00"))) + + if !strings.Contains(out, selectedLabel) { + t.Fatal("output should highlight the selected row label") + } + if !strings.Contains(out, selectedCell) { + t.Fatal("output should keep the selected cell highlighted") + } + if !strings.Contains(out, selectedTotal) { + t.Fatal("output should highlight the selected row total") + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 1f22422..6a3dc33 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -19,9 +19,10 @@ var ( ) var ( - headerStyle = lipgloss.NewStyle().Bold(true) - weekendStyle = lipgloss.NewStyle().Faint(true) - totalsStyle = lipgloss.NewStyle().Bold(true) + headerStyle = lipgloss.NewStyle().Bold(true) + weekendStyle = lipgloss.NewStyle().Faint(true) + totalsStyle = lipgloss.NewStyle().Bold(true) + rowCursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")) // Cursor: bold white on blue background instead of plain Reverse. cursorStyle = lipgloss.NewStyle(). From 9511809c7b755801d15cce283eebc586880d6d45 Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Sun, 15 Mar 2026 15:28:56 +0100 Subject: [PATCH 11/12] Refactor to use `Deps` struct for dependency injection, enhance client/config handling, add dependency tests, and relocate `ParseWeekMonday` to `parsing` package. All fixes are responses for claude code review --- cmd/config/config.go | 2 +- cmd/config/install.go | 4 +- cmd/odoo-work-cli/main.go | 6 +- cmd/timesheet/timesheet.go | 4 +- cmd/tui/tui.go | 7 +- internal/app/deps.go | 28 ++++++++ internal/app/deps_test.go | 134 +++++++++++++++++++++++++++++++++++ internal/odoo/client.go | 2 + internal/odoo/whoami_test.go | 2 + internal/parsing/times.go | 27 ++++++- internal/tui/grid.go | 23 ------ internal/tui/model_test.go | 4 +- 12 files changed, 204 insertions(+), 39 deletions(-) create mode 100644 internal/app/deps.go create mode 100644 internal/app/deps_test.go diff --git a/cmd/config/config.go b/cmd/config/config.go index 6e46f0b..1557721 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -1,4 +1,4 @@ -package project +package config import ( "bytes" diff --git a/cmd/config/install.go b/cmd/config/install.go index 8e906bb..0437074 100644 --- a/cmd/config/install.go +++ b/cmd/config/install.go @@ -1,4 +1,4 @@ -package project +package config import ( "fmt" @@ -37,8 +37,6 @@ func installcmd() *cobra.Command { }, } - cmd.Flags().BoolVar(&configMerged, "merged", false, "print merged TOML config (password redacted)") - return cmd } diff --git a/cmd/odoo-work-cli/main.go b/cmd/odoo-work-cli/main.go index 3ddb46d..829487a 100644 --- a/cmd/odoo-work-cli/main.go +++ b/cmd/odoo-work-cli/main.go @@ -36,12 +36,12 @@ func main() { cfg, err := loadConfig(cfgFile) if err != nil { fmt.Println(err) - os.Exit(1) + return err } client, err := newClient(cfg) if err != nil { fmt.Println(err) - os.Exit(1) + return err } deps.Config = cfg deps.Client = client @@ -98,7 +98,7 @@ func loadConfig(cfgFile string) (*config.Config, error) { } // newClient creates a new Odoo client from the merged config. -func newClient(cfg *config.Config) (*odoo.XMLRPCClient, error) { +func newClient(cfg *config.Config) (odoo.Client, error) { return odoo.NewXMLRPCClient(cfg.URL, cfg.Database, cfg.Username, cfg.Password, cfg.WebPassword, cfg.TOTPSecret, cfg.Models) } diff --git a/cmd/timesheet/timesheet.go b/cmd/timesheet/timesheet.go index 6fc670a..e2bffd9 100644 --- a/cmd/timesheet/timesheet.go +++ b/cmd/timesheet/timesheet.go @@ -8,9 +8,9 @@ import ( "github.com/spf13/cobra" ) -var tsWeek string - func CMD(deps *app.Deps) *cobra.Command { + var tsWeek string + cmd := &cobra.Command{ Use: "timesheets", Short: "List Odoo timesheets for a week", diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 8375a4d..8afc1c3 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -3,13 +3,14 @@ package tui import ( tea "charm.land/bubbletea/v2" "github.com/seletz/odoo-work-cli/internal/app" + "github.com/seletz/odoo-work-cli/internal/parsing" "github.com/seletz/odoo-work-cli/internal/tui" "github.com/spf13/cobra" ) -var tuiWeek string - func CMD(deps *app.Deps) *cobra.Command { + var tuiWeek string + cmd := &cobra.Command{ Use: "tui", Short: "Interactive weekly timesheet view", @@ -23,7 +24,7 @@ func CMD(deps *app.Deps) *cobra.Command { return err } - monday, err := tui.ParseWeekMonday(tuiWeek) + monday, err := parsing.ParseWeekMonday(tuiWeek) if err != nil { return err } diff --git a/internal/app/deps.go b/internal/app/deps.go new file mode 100644 index 0000000..edd0758 --- /dev/null +++ b/internal/app/deps.go @@ -0,0 +1,28 @@ +package app + +import ( + "fmt" + + "github.com/seletz/odoo-work-cli/internal/config" + "github.com/seletz/odoo-work-cli/internal/odoo" +) + +// Deps holds runtime dependencies initialized by the root command. +type Deps struct { + Config *config.Config + Client odoo.Client +} + +func (d *Deps) RequireConfig() (*config.Config, error) { + if d == nil || d.Config == nil { + return nil, fmt.Errorf("config not initialized") + } + return d.Config, nil +} + +func (d *Deps) RequireClient() (odoo.Client, error) { + if d == nil || d.Client == nil { + return nil, fmt.Errorf("odoo client not initialized") + } + return d.Client, nil +} diff --git a/internal/app/deps_test.go b/internal/app/deps_test.go new file mode 100644 index 0000000..ffbcba7 --- /dev/null +++ b/internal/app/deps_test.go @@ -0,0 +1,134 @@ +package app + +import ( + "testing" + + "github.com/seletz/odoo-work-cli/internal/config" + "github.com/seletz/odoo-work-cli/internal/odoo" +) + +type stubClient struct{} + +func (s *stubClient) Close() {} + +func (s *stubClient) WhoAmI() (*odoo.UserInfo, error) { + return nil, nil +} + +func (s *stubClient) ListProjects() ([]odoo.ProjectInfo, error) { + return nil, nil +} + +func (s *stubClient) ListAllProjects() ([]odoo.ProjectInfo, error) { + return nil, nil +} + +func (s *stubClient) ListTasks(int64) ([]odoo.TaskInfo, error) { + return nil, nil +} + +func (s *stubClient) ListAllTasks(int64) ([]odoo.TaskInfo, error) { + return nil, nil +} + +func (s *stubClient) ListTimesheets(string, string) ([]odoo.TimesheetEntry, error) { + return nil, nil +} + +func (s *stubClient) GetFields(string) ([]odoo.FieldInfo, error) { + return nil, nil +} + +func (s *stubClient) CreateTimesheet(odoo.TimesheetWriteParams) (int64, error) { + return 0, nil +} + +func (s *stubClient) UpdateTimesheet(int64, map[string]interface{}) error { + return nil +} + +func (s *stubClient) DeleteTimesheet(int64) error { + return nil +} + +func (s *stubClient) ClockIn() (int64, error) { + return 0, nil +} + +func (s *stubClient) ClockOut() (*odoo.AttendanceRecord, error) { + return nil, nil +} + +func (s *stubClient) AttendanceStatus() (*odoo.AttendanceStatus, error) { + return nil, nil +} + +func TestDepsRequireClientReturnsInterface(t *testing.T) { + client := &stubClient{} + deps := &Deps{Client: client} + + got, err := deps.RequireClient() + if err != nil { + t.Fatalf("RequireClient() error = %v, want nil", err) + } + if got != client { + t.Fatalf("RequireClient() returned %T, want original client", got) + } +} + +func TestDepsRequireClientErrorsWhenUninitialized(t *testing.T) { + tests := []struct { + name string + deps *Deps + }{ + {name: "nil deps", deps: nil}, + {name: "nil client", deps: &Deps{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.deps.RequireClient() + if err == nil { + t.Fatal("RequireClient() error = nil, want non-nil") + } + if got != nil { + t.Fatalf("RequireClient() client = %v, want nil", got) + } + }) + } +} + +func TestDepsRequireConfig(t *testing.T) { + cfg := &config.Config{URL: "https://example.com"} + deps := &Deps{Config: cfg} + + got, err := deps.RequireConfig() + if err != nil { + t.Fatalf("RequireConfig() error = %v, want nil", err) + } + if got != cfg { + t.Fatalf("RequireConfig() returned %+v, want original config", got) + } +} + +func TestDepsRequireConfigErrorsWhenUninitialized(t *testing.T) { + tests := []struct { + name string + deps *Deps + }{ + {name: "nil deps", deps: nil}, + {name: "nil config", deps: &Deps{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.deps.RequireConfig() + if err == nil { + t.Fatal("RequireConfig() error = nil, want non-nil") + } + if got != nil { + t.Fatalf("RequireConfig() config = %+v, want nil", got) + } + }) + } +} diff --git a/internal/odoo/client.go b/internal/odoo/client.go index 4ddf5af..0d5cede 100644 --- a/internal/odoo/client.go +++ b/internal/odoo/client.go @@ -92,6 +92,8 @@ type AttendanceStatus struct { // Client defines the interface for interacting with an Odoo instance. type Client interface { + // Close releases underlying client resources. + Close() // WhoAmI returns the identity of the currently authenticated user. WhoAmI() (*UserInfo, error) // ListProjects returns projects from Odoo, applying configured filters. diff --git a/internal/odoo/whoami_test.go b/internal/odoo/whoami_test.go index 7a47f5b..90b454e 100644 --- a/internal/odoo/whoami_test.go +++ b/internal/odoo/whoami_test.go @@ -34,6 +34,8 @@ func (m *mockClient) WhoAmI() (*UserInfo, error) { return m.info, m.err } +func (m *mockClient) Close() {} + func (m *mockClient) ListProjects() ([]ProjectInfo, error) { return m.projects, m.projErr } diff --git a/internal/parsing/times.go b/internal/parsing/times.go index e6a03ef..c6385d1 100644 --- a/internal/parsing/times.go +++ b/internal/parsing/times.go @@ -3,8 +3,6 @@ package parsing import ( "fmt" "time" - - "github.com/seletz/odoo-work-cli/internal/tui" ) // ParseDateRange returns a single-day date range for the given YYYY-MM-DD string. @@ -20,10 +18,33 @@ func ParseDateRange(date string) (string, string, error) { // WeekDateRange returns the Monday and Sunday of the ISO week specified // as "2006-W02" format, or the current week if empty. func WeekDateRange(week string) (string, string, error) { - monday, err := tui.ParseWeekMonday(week) + monday, err := ParseWeekMonday(week) if err != nil { return "", "", err } sunday := monday.AddDate(0, 0, 6) return monday.Format("2006-01-02"), sunday.Format("2006-01-02"), nil } + +// ParseWeekMonday parses an ISO week string (e.g. "2026-W10") and returns +// the Monday of that week. If week is empty, returns the Monday of the +// current week. +func ParseWeekMonday(week string) (time.Time, error) { + var year, isoWeek int + if week == "" { + now := time.Now() + year, isoWeek = now.ISOWeek() + } else { + _, err := fmt.Sscanf(week, "%d-W%d", &year, &isoWeek) + if err != nil { + return time.Time{}, fmt.Errorf("invalid week format %q (expected YYYY-Www): %w", week, err) + } + } + jan4 := time.Date(year, 1, 4, 0, 0, 0, 0, time.Local) + weekday := jan4.Weekday() + if weekday == 0 { + weekday = 7 + } + monday1 := jan4.AddDate(0, 0, -int(weekday-1)) + return monday1.AddDate(0, 0, (isoWeek-1)*7), nil +} diff --git a/internal/tui/grid.go b/internal/tui/grid.go index 9d4e459..d5334ed 100644 --- a/internal/tui/grid.go +++ b/internal/tui/grid.go @@ -239,29 +239,6 @@ func hintIdentity(h HintRow) string { return h.Label } -// ParseWeekMonday parses an ISO week string (e.g. "2026-W10") and returns -// the Monday of that week. If week is empty, returns the Monday of the -// current week. -func ParseWeekMonday(week string) (time.Time, error) { - var year, isoWeek int - if week == "" { - now := time.Now() - year, isoWeek = now.ISOWeek() - } else { - _, err := fmt.Sscanf(week, "%d-W%d", &year, &isoWeek) - if err != nil { - return time.Time{}, fmt.Errorf("invalid week format %q (expected YYYY-Www): %w", week, err) - } - } - jan4 := time.Date(year, 1, 4, 0, 0, 0, 0, time.Local) - weekday := jan4.Weekday() - if weekday == 0 { - weekday = 7 - } - monday1 := jan4.AddDate(0, 0, -int(weekday-1)) - return monday1.AddDate(0, 0, (isoWeek-1)*7), nil -} - // TodayColumn returns the column index (0=Mon .. 6=Sun) for the given time // relative to the week starting at monday. Returns 0 if now falls outside the // displayed week. diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 671dd4f..4b284a3 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -12,6 +12,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/seletz/odoo-work-cli/internal/config" "github.com/seletz/odoo-work-cli/internal/odoo" + "github.com/seletz/odoo-work-cli/internal/parsing" ) // mockClient implements odoo.Client for testing. @@ -43,6 +44,7 @@ type mockClient struct { } func (c *mockClient) WhoAmI() (*odoo.UserInfo, error) { return nil, nil } +func (c *mockClient) Close() {} func (c *mockClient) GetFields(string) ([]odoo.FieldInfo, error) { return nil, nil } func (c *mockClient) ListProjects() ([]odoo.ProjectInfo, error) { return c.projects, c.projectsErr @@ -121,7 +123,7 @@ func TestModel_CursorOnTodayColumn(t *testing.T) { // Use the current week so TodayColumn returns the real day offset. now := time.Now() year, week := now.ISOWeek() - mon, _ := ParseWeekMonday(fmt.Sprintf("%d-W%02d", year, week)) + mon, _ := parsing.ParseWeekMonday(fmt.Sprintf("%d-W%02d", year, week)) client := &mockClient{} m := NewModel(client, MondayTime{Time: mon}, config.DefaultHoursLimits(), "Deutschland", nil, nil) From d0246590187500372617407ad3c906a117ce5b46 Mon Sep 17 00:00:00 2001 From: stefanbethge Date: Sun, 15 Mar 2026 18:38:33 +0100 Subject: [PATCH 12/12] Fix RenderGrid test calls after rebase --- internal/tui/render_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go index c8c1da3..c565158 100644 --- a/internal/tui/render_test.go +++ b/internal/tui/render_test.go @@ -37,7 +37,7 @@ func TestRenderGrid_ShowsCompanyPrefix(t *testing.T) { } g := BuildWeekGrid(entries, monday(2026, 3, 2)) - out := RenderGrid(g, 0, 0, 120, config.DefaultHoursLimits(), [7]string{}, nil) + out := RenderGrid(g, 0, 0, 120, config.DefaultHoursLimits(), [7]string{}, nil, -1) if !strings.Contains(out, "[DIG] Acme / Dev") { t.Error("output should contain prefixed company label '[DIG] Acme / Dev'") @@ -159,7 +159,7 @@ func TestRenderGrid_WrapsLongLabels(t *testing.T) { } g := BuildWeekGrid(entries, monday(2026, 3, 2)) - out := RenderGrid(g, 0, 0, 90, config.DefaultHoursLimits(), [7]string{}, nil) + out := RenderGrid(g, 0, 0, 90, config.DefaultHoursLimits(), [7]string{}, nil, -1) if strings.Contains(out, "…") { t.Fatal("output should wrap long labels instead of truncating with ellipsis") @@ -182,7 +182,7 @@ func TestRenderGrid_HighlightsWholeSelectedRow(t *testing.T) { } g := BuildWeekGrid(entries, monday(2026, 3, 2)) - out := RenderGrid(g, 1, 0, 120, config.DefaultHoursLimits(), [7]string{}, nil) + out := RenderGrid(g, 1, 0, 120, config.DefaultHoursLimits(), [7]string{}, nil, -1) selectedLabel := rowCursorStyle.Render(fmt.Sprintf("%-*s", 40, "Beta / QA")) selectedCell := cursorStyle.Render(fmt.Sprintf("%*s", 9, "2:00"))