From cf96fd591e49823d60fff79c66c9dd4d60bca581 Mon Sep 17 00:00:00 2001 From: Tim Wehrle Date: Sat, 27 Sep 2025 22:51:05 +0200 Subject: [PATCH 1/8] feat: start implementing new timer commands --- internal/api/asana/tasks.go | 31 +++++++++ pkg/cmd/root/root.go | 2 + pkg/cmd/timer/status/status.go | 118 +++++++++++++++++++++++++++++++++ pkg/cmd/timer/timer.go | 18 +++++ pkg/format/format.go | 26 ++++++++ 5 files changed, 195 insertions(+) create mode 100644 pkg/cmd/timer/status/status.go create mode 100644 pkg/cmd/timer/timer.go diff --git a/internal/api/asana/tasks.go b/internal/api/asana/tasks.go index bacdd8c..b8557c2 100644 --- a/internal/api/asana/tasks.go +++ b/internal/api/asana/tasks.go @@ -603,3 +603,34 @@ func (t *Tag) Tasks(c *Client, opts ...*Options) ([]*Task, *NextPage, error) { nextPage, err := c.get(fmt.Sprintf("/tags/%s/tasks", t.ID), nil, &results, opts...) return results, nextPage, err } + +type TimeTrackingEntry struct { + ID string `json:"gid,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + DurationMinutes int `json:"duration_minutes,omitempty"` + EnteredOn string `json:"entered_on,omitempty"` + AttributableTo struct { + ID string `json:"gid,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + Name string `json:"name,omitempty"` + } + CreatedBy struct { + ID string `json:"gid,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + Name string `json:"name,omitempty"` + } + Task *Task `json:"task,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + ApprovalStatus string `json:"approval_status,omitempty"` + BillableStatus string `json:"billable_status,omitempty"` + Description string `json:"description,omitempty"` +} + +func (t *Task) GetTimeTrackingEntries(c *Client, opts ...*Options) ([]*TimeTrackingEntry, *NextPage, error) { + c.trace("Listing time tracking entries for %q", t.Name) + + var results []*TimeTrackingEntry + + nextPage, err := c.get(fmt.Sprintf("/tasks/%s/time_tracking_entries", t.ID), nil, &results, opts...) + return results, nextPage, err +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 72c858a..a7643b3 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -4,6 +4,7 @@ import ( "os" "github.com/timwehrle/asana/pkg/cmd/teams" + "github.com/timwehrle/asana/pkg/cmd/timer" "github.com/MakeNowJust/heredoc" "github.com/timwehrle/asana/pkg/cmd/tags" @@ -70,6 +71,7 @@ func NewCmdRoot(f factory.Factory, buildVersion string) (*cobra.Command, error) cmd.AddCommand(config.NewCmdConfig(f)) cmd.AddCommand(tags.NewCmdTags(f)) cmd.AddCommand(teams.NewCmdTeams(f)) + cmd.AddCommand(timer.NewCmdTimer(f)) cmd.SilenceErrors = true cmd.SilenceUsage = true diff --git a/pkg/cmd/timer/status/status.go b/pkg/cmd/timer/status/status.go new file mode 100644 index 0000000..babab1d --- /dev/null +++ b/pkg/cmd/timer/status/status.go @@ -0,0 +1,118 @@ +package status + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + "github.com/timwehrle/asana/internal/api/asana" + "github.com/timwehrle/asana/internal/config" + "github.com/timwehrle/asana/internal/prompter" + "github.com/timwehrle/asana/pkg/factory" + "github.com/timwehrle/asana/pkg/format" + "github.com/timwehrle/asana/pkg/iostreams" +) + +type StatusOptions struct { + IO *iostreams.IOStreams + Prompter prompter.Prompter + + Config func() (*config.Config, error) + Client func() (*asana.Client, error) +} + +func NewCmdStatus(f factory.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{ + IO: f.IOStreams, + Prompter: f.Prompter, + Config: f.Config, + Client: f.Client, + } + + cmd := &cobra.Command{ + Use: "status", + Short: "Show the current tracked time of the selected task", + Long: heredoc.Doc(` + Show the total time tracked for a selected task in your Asana workspace. + `), + Example: heredoc.Doc(` + # Show the tracked time of a selected task + $ asana timer status + `), + RunE: func(cmd *cobra.Command, args []string) error { + if runF == nil { + return runStatus(opts) + } + return runF(opts) + }, + } + + return cmd +} + +func runStatus(opts *StatusOptions) error { + io := opts.IO + cs := io.ColorScheme() + + client, err := opts.Client() + if err != nil { + return err + } + + task, err := selectTask(opts, client) + if err != nil { + return err + } + + entries, _, err := task.GetTimeTrackingEntries(client) + if err != nil { + return fmt.Errorf("failed to get time tracking entries: %w", err) + } + + if len(entries) == 0 { + io.Println("No time tracked yet for this task.") + return nil + } + + for _, entry := range entries { + io.Printf("\nYou have tracked %s on task %q.\n\n", cs.Bold(format.Duration(entry.DurationMinutes)), task.Name) + } + + return nil +} + +func selectTask(opts *StatusOptions, c *asana.Client) (*asana.Task, error) { + cfg, err := opts.Config() + if err != nil { + return nil, fmt.Errorf("failed to get config: %w", err) + } + + tasks, _, err := c.QueryTasks(&asana.TaskQuery{ + Assignee: "me", + Workspace: cfg.Workspace.ID, + CompletedSince: "now", + }, &asana.Options{ + Fields: []string{"name", "due_on"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to query tasks: %w", err) + } + + if len(tasks) == 0 { + opts.IO.Println("No tasks found.") + return nil, nil + } + + taskNames := format.Tasks(tasks) + index, err := opts.Prompter.Select("Select the task to see the time of:", taskNames) + if err != nil { + return nil, fmt.Errorf("failed to select task: %w", err) + } + + selectedTask := tasks[index] + if err := selectedTask.Fetch(c); err != nil { + return nil, fmt.Errorf("failed to fetch task details: %w", err) + } + + return selectedTask, nil +} diff --git a/pkg/cmd/timer/timer.go b/pkg/cmd/timer/timer.go new file mode 100644 index 0000000..b0bf17d --- /dev/null +++ b/pkg/cmd/timer/timer.go @@ -0,0 +1,18 @@ +package timer + +import ( + "github.com/spf13/cobra" + "github.com/timwehrle/asana/pkg/cmd/timer/status" + "github.com/timwehrle/asana/pkg/factory" +) + +func NewCmdTimer(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "timer", + Short: "Manage the time tracking of your Asana tasks", + } + + cmd.AddCommand(status.NewCmdStatus(f, nil)) + + return cmd +} diff --git a/pkg/format/format.go b/pkg/format/format.go index 4576d9b..7c451da 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -131,3 +131,29 @@ func Dedent(s string) string { } return strings.TrimSuffix(buffer.String(), "\n") } + +func Duration(minutes int) string { + hours := minutes / 60 + mins := minutes % 60 + + var parts []string + if hours > 0 { + if hours == 1 { + parts = append(parts, "1 hour") + } else { + parts = append(parts, fmt.Sprintf("%d hours", hours)) + } + } + if mins > 0 { + if mins == 1 { + parts = append(parts, "1 minute") + } else { + parts = append(parts, fmt.Sprintf("%d minutes", mins)) + } + } + + if len(parts) == 0 { + return "0 minutes" + } + return strings.Join(parts, " ") +} From a3fbf08bae0b7c0705a2e26fc6180aa5228c8e26 Mon Sep 17 00:00:00 2001 From: Tim Wehrle Date: Sun, 28 Sep 2025 17:54:13 +0200 Subject: [PATCH 2/8] feat: adjust displaying of tracked time entries --- internal/api/asana/tasks.go | 6 +----- pkg/cmd/timer/status/status.go | 8 ++++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/api/asana/tasks.go b/internal/api/asana/tasks.go index b8557c2..2fe5dbb 100644 --- a/internal/api/asana/tasks.go +++ b/internal/api/asana/tasks.go @@ -614,11 +614,7 @@ type TimeTrackingEntry struct { ResourceType string `json:"resource_type,omitempty"` Name string `json:"name,omitempty"` } - CreatedBy struct { - ID string `json:"gid,omitempty"` - ResourceType string `json:"resource_type,omitempty"` - Name string `json:"name,omitempty"` - } + CreatedBy *User `json:"created_by,omitempty"` Task *Task `json:"task,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` ApprovalStatus string `json:"approval_status,omitempty"` diff --git a/pkg/cmd/timer/status/status.go b/pkg/cmd/timer/status/status.go index babab1d..b4f4fc8 100644 --- a/pkg/cmd/timer/status/status.go +++ b/pkg/cmd/timer/status/status.go @@ -64,7 +64,9 @@ func runStatus(opts *StatusOptions) error { return err } - entries, _, err := task.GetTimeTrackingEntries(client) + entries, _, err := task.GetTimeTrackingEntries(client, &asana.Options{ + Fields: []string{"created_by.name", "created_by.gid", "duration_minutes"}, + }) if err != nil { return fmt.Errorf("failed to get time tracking entries: %w", err) } @@ -74,9 +76,11 @@ func runStatus(opts *StatusOptions) error { return nil } + io.Printf("\nTracked time entries on task %s:\n", cs.Bold(task.Name)) for _, entry := range entries { - io.Printf("\nYou have tracked %s on task %q.\n\n", cs.Bold(format.Duration(entry.DurationMinutes)), task.Name) + io.Printf("\n- %s tracked %s", entry.CreatedBy.Name, cs.Bold(format.Duration(entry.DurationMinutes))) } + io.Printf("\n\n") return nil } From 67260730d1975fdf8746a08e1457124a372d000d Mon Sep 17 00:00:00 2001 From: Tim Wehrle Date: Sun, 28 Sep 2025 18:09:28 +0200 Subject: [PATCH 3/8] feat: update displaying and adjust types of time tracking entry --- internal/api/asana/tasks.go | 2 +- pkg/cmd/timer/status/status.go | 32 ++++++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/api/asana/tasks.go b/internal/api/asana/tasks.go index 2fe5dbb..23d24f4 100644 --- a/internal/api/asana/tasks.go +++ b/internal/api/asana/tasks.go @@ -608,7 +608,7 @@ type TimeTrackingEntry struct { ID string `json:"gid,omitempty"` ResourceType string `json:"resource_type,omitempty"` DurationMinutes int `json:"duration_minutes,omitempty"` - EnteredOn string `json:"entered_on,omitempty"` + EnteredOn *Date `json:"entered_on,omitempty"` AttributableTo struct { ID string `json:"gid,omitempty"` ResourceType string `json:"resource_type,omitempty"` diff --git a/pkg/cmd/timer/status/status.go b/pkg/cmd/timer/status/status.go index b4f4fc8..d0c9cde 100644 --- a/pkg/cmd/timer/status/status.go +++ b/pkg/cmd/timer/status/status.go @@ -2,6 +2,8 @@ package status import ( "fmt" + "sort" + "time" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" @@ -65,7 +67,7 @@ func runStatus(opts *StatusOptions) error { } entries, _, err := task.GetTimeTrackingEntries(client, &asana.Options{ - Fields: []string{"created_by.name", "created_by.gid", "duration_minutes"}, + Fields: []string{"created_by.name", "created_by.gid", "duration_minutes", "entered_on"}, }) if err != nil { return fmt.Errorf("failed to get time tracking entries: %w", err) @@ -76,12 +78,34 @@ func runStatus(opts *StatusOptions) error { return nil } - io.Printf("\nTracked time entries on task %s:\n", cs.Bold(task.Name)) + grouped := make(map[*asana.Date][]*asana.TimeTrackingEntry) + totalMinutes := 0 for _, entry := range entries { - io.Printf("\n- %s tracked %s", entry.CreatedBy.Name, cs.Bold(format.Duration(entry.DurationMinutes))) + grouped[entry.EnteredOn] = append(grouped[entry.EnteredOn], entry) + totalMinutes += entry.DurationMinutes + } + + dates := make([]*asana.Date, 0, len(grouped)) + for d := range grouped { + dates = append(dates, d) + } + + sort.Slice(dates, func(i, j int) bool { + return time.Time(*dates[i]).After(time.Time(*dates[j])) + }) + + io.Printf("\nTracked time entries on task %s:\n", cs.Bold(task.Name)) + for _, d := range dates { + io.Printf("\n[%s]\n", format.Date(d)) + for _, entry := range grouped[d] { + io.Printf("- %s tracked %s\n", + entry.CreatedBy.Name, + cs.Bold(format.Duration(entry.DurationMinutes)), + ) + } } - io.Printf("\n\n") + io.Printf("\nTotal tracked time: %s\n", cs.Bold(format.Duration(totalMinutes))) return nil } From 2c4cee6299d3bdd295ae0ac824b36dbcf53b43f1 Mon Sep 17 00:00:00 2001 From: Tim Wehrle Date: Sun, 28 Sep 2025 22:55:10 +0200 Subject: [PATCH 4/8] feat: change name and add create command --- internal/api/asana/tasks.go | 32 +++-- pkg/cmd/root/root.go | 4 +- pkg/cmd/time/create/create.go | 147 +++++++++++++++++++++++ pkg/cmd/{timer => time}/status/status.go | 82 +++++++++---- pkg/cmd/time/time.go | 20 +++ pkg/cmd/timer/timer.go | 18 --- 6 files changed, 250 insertions(+), 53 deletions(-) create mode 100644 pkg/cmd/time/create/create.go rename pkg/cmd/{timer => time}/status/status.go (62%) create mode 100644 pkg/cmd/time/time.go delete mode 100644 pkg/cmd/timer/timer.go diff --git a/internal/api/asana/tasks.go b/internal/api/asana/tasks.go index 23d24f4..66a226b 100644 --- a/internal/api/asana/tasks.go +++ b/internal/api/asana/tasks.go @@ -158,12 +158,12 @@ type CreateTaskRequest struct { Assignee string `json:"assignee,omitempty"` // User to which this task is assigned, or null if the task is unassigned. Followers []string `json:"followers,omitempty"` // Array of users following this task. - Workspace string `json:"workspace,omitempty"` - Parent string `json:"parent,omitempty"` - Projects []string `json:"projects,omitempty"` - Memberships []*CreateMembership `json:"memberships,omitempty"` - Tags []string `json:"tags,omitempty"` - CustomFields map[string]interface{} `json:"custom_fields,omitempty"` + Workspace string `json:"workspace,omitempty"` + Parent string `json:"parent,omitempty"` + Projects []string `json:"projects,omitempty"` + Memberships []*CreateMembership `json:"memberships,omitempty"` + Tags []string `json:"tags,omitempty"` + CustomFields map[string]any `json:"custom_fields,omitempty"` } type CreateMembership struct { @@ -174,9 +174,9 @@ type CreateMembership struct { type UpdateTaskRequest struct { TaskBase - Assignee string `json:"assignee,omitempty"` // User to which this task is assigned, or null if the task is unassigned. - Followers []string `json:"followers,omitempty"` // Array of users following this task. - CustomFields map[string]interface{} `json:"custom_fields,omitempty"` + Assignee string `json:"assignee,omitempty"` // User to which this task is assigned, or null if the task is unassigned. + Followers []string `json:"followers,omitempty"` // Array of users following this task. + CustomFields map[string]any `json:"custom_fields,omitempty"` } // Task is the basic object around which many operations in Asana are @@ -630,3 +630,17 @@ func (t *Task) GetTimeTrackingEntries(c *Client, opts ...*Options) ([]*TimeTrack nextPage, err := c.get(fmt.Sprintf("/tasks/%s/time_tracking_entries", t.ID), nil, &results, opts...) return results, nextPage, err } + +type CreateTimeTrackingEntryRequest struct { + DurationMinutes int `json:"duration_minutes,omitempty"` + EnteredOn *Date `json:"entered_on,omitempty"` + AttributableTo string `json:"attributable_to,omitempty"` +} + +func (t *Task) CreateTimeTrackingEntry(c *Client, request *CreateTimeTrackingEntryRequest, opts ...*Options) (*TimeTrackingEntry, error) { + c.info("Creating time tracking entry for task %q", t.Name) + + var result *TimeTrackingEntry + err := c.post(fmt.Sprintf("/tasks/%s/time_tracking_entries", t.ID), request, &result, opts...) + return result, err +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index a7643b3..e221489 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -4,7 +4,7 @@ import ( "os" "github.com/timwehrle/asana/pkg/cmd/teams" - "github.com/timwehrle/asana/pkg/cmd/timer" + "github.com/timwehrle/asana/pkg/cmd/time" "github.com/MakeNowJust/heredoc" "github.com/timwehrle/asana/pkg/cmd/tags" @@ -71,7 +71,7 @@ func NewCmdRoot(f factory.Factory, buildVersion string) (*cobra.Command, error) cmd.AddCommand(config.NewCmdConfig(f)) cmd.AddCommand(tags.NewCmdTags(f)) cmd.AddCommand(teams.NewCmdTeams(f)) - cmd.AddCommand(timer.NewCmdTimer(f)) + cmd.AddCommand(time.NewCmdTimer(f)) cmd.SilenceErrors = true cmd.SilenceUsage = true diff --git a/pkg/cmd/time/create/create.go b/pkg/cmd/time/create/create.go new file mode 100644 index 0000000..6a075ce --- /dev/null +++ b/pkg/cmd/time/create/create.go @@ -0,0 +1,147 @@ +package create + +import ( + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + "github.com/timwehrle/asana/internal/api/asana" + "github.com/timwehrle/asana/internal/config" + "github.com/timwehrle/asana/internal/prompter" + "github.com/timwehrle/asana/pkg/convert" + "github.com/timwehrle/asana/pkg/factory" + "github.com/timwehrle/asana/pkg/format" + "github.com/timwehrle/asana/pkg/iostreams" +) + +type CreateOptions struct { + IO *iostreams.IOStreams + Prompter prompter.Prompter + + Config func() (*config.Config, error) + Client func() (*asana.Client, error) +} + +func NewCmdCreate(f factory.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + Prompter: f.Prompter, + Config: f.Config, + Client: f.Client, + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new time entry for a task", + Long: "Create and log a new time entry on a selected Asana task.", + RunE: func(cmd *cobra.Command, args []string) error { + if runF == nil { + return runCreate(opts) + } + + return runF(opts) + }, + } + + return cmd +} + +func runCreate(opts *CreateOptions) error { + io := opts.IO + cs := io.ColorScheme() + + client, err := opts.Client() + if err != nil { + return err + } + + task, err := selectTask(opts, client) + if err != nil { + return err + } + + minutes, err := promptDuration(opts) + if err != nil { + return err + } + + date, err := promptDate(opts) + if err != nil { + return err + } + + result, err := task.CreateTimeTrackingEntry(client, &asana.CreateTimeTrackingEntryRequest{ + DurationMinutes: minutes, + EnteredOn: date, + }) + if err != nil { + return fmt.Errorf("failed to create time tracking entry: %w", err) + } + + io.Printf("%s Logged %s to %q (%s)\n", cs.SuccessIcon, format.Duration(result.DurationMinutes), task.Name, format.HumanDate(*result.CreatedAt)) + return nil +} + +func promptDuration(opts *CreateOptions) (int, error) { + minutesStr, err := opts.Prompter.Input("How many minutes do you want to log? (e.g., 30)", "") + if err != nil { + return 0, fmt.Errorf("failed to read duration: %w", err) + } + + minutes, err := strconv.Atoi(minutesStr) + if err != nil || minutes <= 0 { + return 0, fmt.Errorf("invalid duration: must be a positive number") + } + return minutes, nil +} + +func promptDate(opts *CreateOptions) (*asana.Date, error) { + input, err := opts.Prompter.Input("Enter date [YYYY-MM-DD] or leave empty for today:", "") + if err != nil { + return nil, fmt.Errorf("failed to read date: %w", err) + } + + if input == "" { + today := asana.Date(time.Now()) + return &today, nil + } + + return convert.ToDate(input, "2006-01-02") +} + +func selectTask(opts *CreateOptions, c *asana.Client) (*asana.Task, error) { + cfg, err := opts.Config() + if err != nil { + return nil, fmt.Errorf("failed to get config: %w", err) + } + + tasks, _, err := c.QueryTasks(&asana.TaskQuery{ + Assignee: "me", + Workspace: cfg.Workspace.ID, + CompletedSince: "now", + }, &asana.Options{ + Fields: []string{"name", "due_on"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to query tasks: %w", err) + } + + if len(tasks) == 0 { + opts.IO.Println("No tasks found.") + return nil, nil + } + + taskNames := format.Tasks(tasks) + index, err := opts.Prompter.Select("Select the task to see the time of:", taskNames) + if err != nil { + return nil, fmt.Errorf("failed to select task: %w", err) + } + + selectedTask := tasks[index] + if err := selectedTask.Fetch(c); err != nil { + return nil, fmt.Errorf("failed to fetch task details: %w", err) + } + + return selectedTask, nil +} diff --git a/pkg/cmd/timer/status/status.go b/pkg/cmd/time/status/status.go similarity index 62% rename from pkg/cmd/timer/status/status.go rename to pkg/cmd/time/status/status.go index d0c9cde..62876fd 100644 --- a/pkg/cmd/timer/status/status.go +++ b/pkg/cmd/time/status/status.go @@ -33,9 +33,10 @@ func NewCmdStatus(f factory.Factory, runF func(*StatusOptions) error) *cobra.Com cmd := &cobra.Command{ Use: "status", - Short: "Show the current tracked time of the selected task", + Short: "Show tracked time for a task", Long: heredoc.Doc(` - Show the total time tracked for a selected task in your Asana workspace. + Display all time entries logged on a selected Asana task, grouped by date, + along with the total tracked time. `), Example: heredoc.Doc(` # Show the tracked time of a selected task @@ -52,6 +53,12 @@ func NewCmdStatus(f factory.Factory, runF func(*StatusOptions) error) *cobra.Com return cmd } +type GroupedEntries struct { + Date time.Time + Label string + Entries []*asana.TimeTrackingEntry +} + func runStatus(opts *StatusOptions) error { io := opts.IO cs := io.ColorScheme() @@ -74,41 +81,68 @@ func runStatus(opts *StatusOptions) error { } if len(entries) == 0 { - io.Println("No time tracked yet for this task.") + io.Println("No time entries found for this task.") return nil } - grouped := make(map[*asana.Date][]*asana.TimeTrackingEntry) - totalMinutes := 0 - for _, entry := range entries { - grouped[entry.EnteredOn] = append(grouped[entry.EnteredOn], entry) - totalMinutes += entry.DurationMinutes - } - - dates := make([]*asana.Date, 0, len(grouped)) - for d := range grouped { - dates = append(dates, d) + groups, total, err := groupEntries(entries) + if err != nil { + return err } - sort.Slice(dates, func(i, j int) bool { - return time.Time(*dates[i]).After(time.Time(*dates[j])) - }) - - io.Printf("\nTracked time entries on task %s:\n", cs.Bold(task.Name)) - for _, d := range dates { - io.Printf("\n[%s]\n", format.Date(d)) - for _, entry := range grouped[d] { - io.Printf("- %s tracked %s\n", + io.Printf("\nTime entries for task: %s\n", cs.Bold(task.Name)) + for _, g := range groups { + io.Printf("\n[%s]\n", g.Label) + for _, entry := range g.Entries { + io.Printf(" • %s — %s\n", entry.CreatedBy.Name, cs.Bold(format.Duration(entry.DurationMinutes)), ) } } - io.Printf("\nTotal tracked time: %s\n", cs.Bold(format.Duration(totalMinutes))) + io.Printf("\nTotal: %s\n", cs.Bold(format.Duration(total))) return nil } +func groupEntries(entries []*asana.TimeTrackingEntry) ([]GroupedEntries, int, error) { + m := map[string]*GroupedEntries{} + total := 0 + + for _, e := range entries { + if e.EnteredOn == nil { + continue + } + + key := time.Time(*e.EnteredOn).Format("2006-01-02") + t, err := time.Parse("2006-01-02", key) + if err != nil { + return nil, 0, fmt.Errorf("invalid entered_on date: %w", err) + } + + if _, ok := m[key]; !ok { + m[key] = &GroupedEntries{ + Date: t, + Label: format.HumanDate(t), + } + } + + m[key].Entries = append(m[key].Entries, e) + total += e.DurationMinutes + } + + groups := make([]GroupedEntries, 0, len(m)) + for _, g := range m { + groups = append(groups, *g) + } + + sort.Slice(groups, func(i, j int) bool { + return groups[i].Date.After(groups[j].Date) + }) + + return groups, total, nil +} + func selectTask(opts *StatusOptions, c *asana.Client) (*asana.Task, error) { cfg, err := opts.Config() if err != nil { @@ -132,7 +166,7 @@ func selectTask(opts *StatusOptions, c *asana.Client) (*asana.Task, error) { } taskNames := format.Tasks(tasks) - index, err := opts.Prompter.Select("Select the task to see the time of:", taskNames) + index, err := opts.Prompter.Select("Select a task to view tracked time:", taskNames) if err != nil { return nil, fmt.Errorf("failed to select task: %w", err) } diff --git a/pkg/cmd/time/time.go b/pkg/cmd/time/time.go new file mode 100644 index 0000000..d044f4d --- /dev/null +++ b/pkg/cmd/time/time.go @@ -0,0 +1,20 @@ +package time + +import ( + "github.com/spf13/cobra" + "github.com/timwehrle/asana/pkg/cmd/time/create" + "github.com/timwehrle/asana/pkg/cmd/time/status" + "github.com/timwehrle/asana/pkg/factory" +) + +func NewCmdTimer(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "time", + Short: "Manage time tracking for your Asana tasks", + Long: "Commands to track, delete and inspect time entries on your Asana tasks.", + } + + cmd.AddCommand(status.NewCmdStatus(f, nil), create.NewCmdCreate(f, nil)) + + return cmd +} diff --git a/pkg/cmd/timer/timer.go b/pkg/cmd/timer/timer.go deleted file mode 100644 index b0bf17d..0000000 --- a/pkg/cmd/timer/timer.go +++ /dev/null @@ -1,18 +0,0 @@ -package timer - -import ( - "github.com/spf13/cobra" - "github.com/timwehrle/asana/pkg/cmd/timer/status" - "github.com/timwehrle/asana/pkg/factory" -) - -func NewCmdTimer(f factory.Factory) *cobra.Command { - cmd := &cobra.Command{ - Use: "timer", - Short: "Manage the time tracking of your Asana tasks", - } - - cmd.AddCommand(status.NewCmdStatus(f, nil)) - - return cmd -} From a7e643f6a75f91590d6afd9a2bc60db0d2700895 Mon Sep 17 00:00:00 2001 From: Tim Wehrle Date: Sun, 28 Sep 2025 22:55:23 +0200 Subject: [PATCH 5/8] feat: add new formatting for human date --- pkg/format/format.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/format/format.go b/pkg/format/format.go index 7c451da..99a4e7f 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -100,6 +100,20 @@ func Date(date *asana.Date) string { return parsedDate.Format("Jan 02, 2006") } +func HumanDate(t time.Time) string { + today := time.Now().Truncate(24 * time.Hour) + d := t.Truncate(24 * time.Hour) + + switch { + case d.Equal(today): + return "Today" + case d.Equal(today.AddDate(0, 0, -1)): + return "Yesterday" + default: + return d.Format("Jan 02, 2006") + } +} + func Indent(s, prefix string) string { if len(strings.TrimSpace(s)) == 0 { return s From dd9781459043fd89debb4f66af3e01b605431980 Mon Sep 17 00:00:00 2001 From: Tim Wehrle Date: Sun, 28 Sep 2025 23:17:49 +0200 Subject: [PATCH 6/8] chore: bump go version to 1.24.7 to resolve vulnerabilities --- go.mod | 48 +++++++++++++--------------- go.sum | 99 ++++++++++++++++++++++++---------------------------------- 2 files changed, 61 insertions(+), 86 deletions(-) diff --git a/go.mod b/go.mod index 646c84e..53cbedf 100644 --- a/go.mod +++ b/go.mod @@ -1,60 +1,54 @@ module github.com/timwehrle/asana -go 1.23.0 +go 1.24.7 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 - github.com/spf13/cobra v1.8.1 + github.com/spf13/cobra v1.10.1 github.com/zalando/go-keyring v0.2.6 ) require ( - dario.cat/mergo v1.0.1 + dario.cat/mergo v1.0.2 github.com/google/go-querystring v1.1.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/h2non/gock v1.2.0 github.com/pkg/errors v0.9.1 github.com/rs/xid v1.6.0 - github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.9.0 - golang.org/x/oauth2 v0.27.0 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.31.0 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( - al.essio.dev/pkg/shellescape v1.5.1 // indirect + al.essio.dev/pkg/shellescape v1.6.0 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/mattn/go-colorable v0.1.13 + github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-isatty v0.0.20 - github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b - github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect - golang.org/x/text v0.21.0 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 9713e93..ac60c82 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,14 @@ -al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= -al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= @@ -19,8 +19,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -34,8 +36,6 @@ github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -46,23 +46,19 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -73,53 +69,40 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= -golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -128,19 +111,19 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -149,8 +132,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 909866710684b660273f0d51417cdf8925ea9651 Mon Sep 17 00:00:00 2001 From: Tim Wehrle Date: Mon, 29 Sep 2025 19:31:20 +0200 Subject: [PATCH 7/8] feat: add new delete time tracking entry command --- internal/api/asana/tasks.go | 18 ---- internal/api/asana/time_tracking.go | 28 +++++++ pkg/cmd/time/delete/delete.go | 125 ++++++++++++++++++++++++++++ pkg/cmd/time/time.go | 3 +- 4 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 internal/api/asana/time_tracking.go create mode 100644 pkg/cmd/time/delete/delete.go diff --git a/internal/api/asana/tasks.go b/internal/api/asana/tasks.go index 66a226b..8e97c2f 100644 --- a/internal/api/asana/tasks.go +++ b/internal/api/asana/tasks.go @@ -604,24 +604,6 @@ func (t *Tag) Tasks(c *Client, opts ...*Options) ([]*Task, *NextPage, error) { return results, nextPage, err } -type TimeTrackingEntry struct { - ID string `json:"gid,omitempty"` - ResourceType string `json:"resource_type,omitempty"` - DurationMinutes int `json:"duration_minutes,omitempty"` - EnteredOn *Date `json:"entered_on,omitempty"` - AttributableTo struct { - ID string `json:"gid,omitempty"` - ResourceType string `json:"resource_type,omitempty"` - Name string `json:"name,omitempty"` - } - CreatedBy *User `json:"created_by,omitempty"` - Task *Task `json:"task,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` - ApprovalStatus string `json:"approval_status,omitempty"` - BillableStatus string `json:"billable_status,omitempty"` - Description string `json:"description,omitempty"` -} - func (t *Task) GetTimeTrackingEntries(c *Client, opts ...*Options) ([]*TimeTrackingEntry, *NextPage, error) { c.trace("Listing time tracking entries for %q", t.Name) diff --git a/internal/api/asana/time_tracking.go b/internal/api/asana/time_tracking.go new file mode 100644 index 0000000..ab03382 --- /dev/null +++ b/internal/api/asana/time_tracking.go @@ -0,0 +1,28 @@ +package asana + +import "time" + +type TimeTrackingEntry struct { + ID string `json:"gid,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + DurationMinutes int `json:"duration_minutes,omitempty"` + EnteredOn *Date `json:"entered_on,omitempty"` + AttributableTo struct { + ID string `json:"gid,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + Name string `json:"name,omitempty"` + } + CreatedBy *User `json:"created_by,omitempty"` + Task *Task `json:"task,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + ApprovalStatus string `json:"approval_status,omitempty"` + BillableStatus string `json:"billable_status,omitempty"` + Description string `json:"description,omitempty"` +} + +func (t *TimeTrackingEntry) Delete(c *Client, opts ...*Options) error { + c.trace("Removing time tracking entry %q", t.ID) + + err := c.delete("/time_tracking_entries/"+t.ID, opts...) + return err +} diff --git a/pkg/cmd/time/delete/delete.go b/pkg/cmd/time/delete/delete.go new file mode 100644 index 0000000..1cddc55 --- /dev/null +++ b/pkg/cmd/time/delete/delete.go @@ -0,0 +1,125 @@ +package delete + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/timwehrle/asana/internal/api/asana" + "github.com/timwehrle/asana/internal/config" + "github.com/timwehrle/asana/internal/prompter" + "github.com/timwehrle/asana/pkg/factory" + "github.com/timwehrle/asana/pkg/format" + "github.com/timwehrle/asana/pkg/iostreams" +) + +type DeleteOptions struct { + IO *iostreams.IOStreams + Prompter prompter.Prompter + + Config func() (*config.Config, error) + Client func() (*asana.Client, error) +} + +func NewCmdDelete(f factory.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + Prompter: f.Prompter, + Config: f.Config, + Client: f.Client, + } + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a time entry from a task", + Long: "Delete and remove a time entry from a selected Asana task.", + RunE: func(cmd *cobra.Command, args []string) error { + if runF == nil { + return runDelete(opts) + } + + return runF(opts) + }, + } + + return cmd +} + +func runDelete(opts *DeleteOptions) error { + io := opts.IO + + client, err := opts.Client() + if err != nil { + return err + } + + task, err := selectTask(opts, client) + if err != nil { + return err + } + + entries, _, err := task.GetTimeTrackingEntries(client, &asana.Options{ + Fields: []string{"created_by.name", "created_by.gid", "duration_minutes", "entered_on"}, + }) + if err != nil { + return fmt.Errorf("failed to get time tracking entries: %w", err) + } + + if len(entries) == 0 { + io.Println("No time entries found for this task.") + return nil + } + + entryLabels := format.MapToStrings(entries, func(e *asana.TimeTrackingEntry) string { + return fmt.Sprintf("%s — %s", e.CreatedBy.Name, format.Duration(e.DurationMinutes)) + }) + index, err := opts.Prompter.Select("Select a time entry to delete:", entryLabels) + if err != nil { + return fmt.Errorf("failed to select time entry: %w", err) + } + + selectedEntry := entries[index] + if err := selectedEntry.Delete(client); err != nil { + return fmt.Errorf("failed to delete time tracking entry: %w", err) + } + + cs := io.ColorScheme() + io.Printf("%s Deleted time entry of %s created by %s\n", cs.SuccessIcon, format.Duration(selectedEntry.DurationMinutes), selectedEntry.CreatedBy.Name) + + return nil +} + +func selectTask(opts *DeleteOptions, c *asana.Client) (*asana.Task, error) { + cfg, err := opts.Config() + if err != nil { + return nil, fmt.Errorf("failed to get config: %w", err) + } + + tasks, _, err := c.QueryTasks(&asana.TaskQuery{ + Assignee: "me", + Workspace: cfg.Workspace.ID, + CompletedSince: "now", + }, &asana.Options{ + Fields: []string{"name", "due_on"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to query tasks: %w", err) + } + + if len(tasks) == 0 { + opts.IO.Println("No tasks found.") + return nil, nil + } + + taskNames := format.Tasks(tasks) + index, err := opts.Prompter.Select("Select a task to view tracked time:", taskNames) + if err != nil { + return nil, fmt.Errorf("failed to select task: %w", err) + } + + selectedTask := tasks[index] + if err := selectedTask.Fetch(c); err != nil { + return nil, fmt.Errorf("failed to fetch task details: %w", err) + } + + return selectedTask, nil +} diff --git a/pkg/cmd/time/time.go b/pkg/cmd/time/time.go index d044f4d..1888bb8 100644 --- a/pkg/cmd/time/time.go +++ b/pkg/cmd/time/time.go @@ -3,6 +3,7 @@ package time import ( "github.com/spf13/cobra" "github.com/timwehrle/asana/pkg/cmd/time/create" + "github.com/timwehrle/asana/pkg/cmd/time/delete" "github.com/timwehrle/asana/pkg/cmd/time/status" "github.com/timwehrle/asana/pkg/factory" ) @@ -14,7 +15,7 @@ func NewCmdTimer(f factory.Factory) *cobra.Command { Long: "Commands to track, delete and inspect time entries on your Asana tasks.", } - cmd.AddCommand(status.NewCmdStatus(f, nil), create.NewCmdCreate(f, nil)) + cmd.AddCommand(status.NewCmdStatus(f, nil), create.NewCmdCreate(f, nil), delete.NewCmdDelete(f, nil)) return cmd } From a75b7b5a705a692626eda9ac0bac655848dd3381 Mon Sep 17 00:00:00 2001 From: Tim Wehrle Date: Mon, 29 Sep 2025 19:35:07 +0200 Subject: [PATCH 8/8] fix: resolve linting issues --- pkg/cmd/time/create/create.go | 2 +- pkg/cmd/time/status/status.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/time/create/create.go b/pkg/cmd/time/create/create.go index 6a075ce..7e0669e 100644 --- a/pkg/cmd/time/create/create.go +++ b/pkg/cmd/time/create/create.go @@ -107,7 +107,7 @@ func promptDate(opts *CreateOptions) (*asana.Date, error) { return &today, nil } - return convert.ToDate(input, "2006-01-02") + return convert.ToDate(input, time.DateOnly) } func selectTask(opts *CreateOptions, c *asana.Client) (*asana.Task, error) { diff --git a/pkg/cmd/time/status/status.go b/pkg/cmd/time/status/status.go index 62876fd..83f4dcd 100644 --- a/pkg/cmd/time/status/status.go +++ b/pkg/cmd/time/status/status.go @@ -114,8 +114,8 @@ func groupEntries(entries []*asana.TimeTrackingEntry) ([]GroupedEntries, int, er continue } - key := time.Time(*e.EnteredOn).Format("2006-01-02") - t, err := time.Parse("2006-01-02", key) + key := time.Time(*e.EnteredOn).Format(time.DateOnly) + t, err := time.Parse(time.DateOnly, key) if err != nil { return nil, 0, fmt.Errorf("invalid entered_on date: %w", err) }