Skip to content
This repository was archived by the owner on Mar 12, 2026. It is now read-only.

Commit 08535e1

Browse files
authored
Merge pull request #139 from wlb-anthonyleung/feature/138-timesheets
feat: add timesheet command for viewing time entries
2 parents cda5308 + 9cca2b0 commit 08535e1

9 files changed

Lines changed: 913 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/needmore/bc4/cmd/project"
2323
"github.com/needmore/bc4/cmd/schedule"
2424
"github.com/needmore/bc4/cmd/search"
25+
"github.com/needmore/bc4/cmd/timesheet"
2526
"github.com/needmore/bc4/cmd/todo"
2627
"github.com/needmore/bc4/internal/cmdutil"
2728
"github.com/needmore/bc4/internal/config"
@@ -170,6 +171,7 @@ func init() {
170171
rootCmd.AddCommand(profile.NewProfileCmd(f))
171172
rootCmd.AddCommand(schedule.NewScheduleCmd(f))
172173
rootCmd.AddCommand(search.NewSearchCmd(f))
174+
rootCmd.AddCommand(timesheet.NewTimesheetCmd(f))
173175

174176
// Add version command (doesn't need factory)
175177
rootCmd.AddCommand(versionCmd)

cmd/timesheet/list.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package timesheet
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
"github.com/needmore/bc4/internal/api"
12+
"github.com/needmore/bc4/internal/factory"
13+
"github.com/needmore/bc4/internal/parser"
14+
"github.com/needmore/bc4/internal/ui/tableprinter"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
func newListCmd(f *factory.Factory) *cobra.Command {
19+
var (
20+
accountID string
21+
projectID string
22+
personStr string
23+
sinceStr string
24+
formatStr string
25+
recordingID int64
26+
)
27+
28+
cmd := &cobra.Command{
29+
Use: "list [project]",
30+
Short: "List timesheet entries",
31+
Long: `List timesheet entries for a project or recording.`,
32+
Aliases: []string{"ls"},
33+
Args: cobra.MaximumNArgs(1),
34+
RunE: func(cmd *cobra.Command, args []string) error {
35+
// Parse project argument if provided (could be URL or ID)
36+
if len(args) > 0 {
37+
if parser.IsBasecampURL(args[0]) {
38+
parsed, err := parser.ParseBasecampURL(args[0])
39+
if err != nil {
40+
return fmt.Errorf("invalid Basecamp URL: %w", err)
41+
}
42+
if parsed.ResourceType != parser.ResourceTypeProject {
43+
return fmt.Errorf("URL is not for a project: %s", args[0])
44+
}
45+
// Override factory with URL-provided values
46+
if parsed.AccountID > 0 {
47+
f = f.WithAccount(strconv.FormatInt(parsed.AccountID, 10))
48+
}
49+
if parsed.ProjectID > 0 {
50+
f = f.WithProject(strconv.FormatInt(parsed.ProjectID, 10))
51+
}
52+
} else {
53+
// Treat as project ID
54+
f = f.WithProject(args[0])
55+
}
56+
}
57+
58+
// Apply flag overrides (flags take precedence over URL)
59+
if accountID != "" {
60+
f = f.WithAccount(accountID)
61+
}
62+
if projectID != "" {
63+
f = f.WithProject(projectID)
64+
}
65+
66+
// Parse --since before fetching so invalid input fails early
67+
var sinceDate time.Time
68+
if sinceStr != "" {
69+
var err error
70+
sinceDate, err = parseSince(sinceStr)
71+
if err != nil {
72+
return fmt.Errorf("invalid --since value: %w", err)
73+
}
74+
}
75+
76+
// Get API client from factory
77+
client, err := f.ApiClient()
78+
if err != nil {
79+
return err
80+
}
81+
82+
// Get resolved project ID
83+
resolvedProjectID, err := f.ProjectID()
84+
if err != nil {
85+
return err
86+
}
87+
88+
// Fetch timesheet entries
89+
var entries []api.TimesheetEntry
90+
if recordingID > 0 {
91+
// Get entries for a specific recording
92+
entries, err = client.Timesheets().GetRecordingTimesheet(cmd.Context(), resolvedProjectID, recordingID)
93+
} else {
94+
// Get entries for the project
95+
entries, err = client.Timesheets().GetProjectTimesheet(cmd.Context(), resolvedProjectID)
96+
}
97+
if err != nil {
98+
return err
99+
}
100+
101+
// Apply filters
102+
entries = filterEntries(entries, personStr, sinceDate)
103+
104+
// Output format
105+
if formatStr == "json" {
106+
return outputJSON(entries)
107+
}
108+
109+
// Display as table
110+
return renderEntryTable(entries, false)
111+
},
112+
}
113+
114+
cmd.Flags().StringVarP(&accountID, "account", "a", "", "Account ID")
115+
cmd.Flags().StringVarP(&projectID, "project", "p", "", "Project ID")
116+
cmd.Flags().StringVar(&personStr, "person", "", "Filter by person name (case-insensitive substring match)")
117+
cmd.Flags().StringVar(&sinceStr, "since", "", "Show entries since (e.g., '7d', '2024-01-01')")
118+
cmd.Flags().StringVarP(&formatStr, "format", "f", "table", "Output format (table, json)")
119+
cmd.Flags().Int64Var(&recordingID, "recording", 0, "Filter by recording ID")
120+
121+
return cmd
122+
}
123+
124+
// filterEntries applies person and date filters
125+
func filterEntries(entries []api.TimesheetEntry, personStr string, sinceDate time.Time) []api.TimesheetEntry {
126+
var filtered []api.TimesheetEntry
127+
128+
for _, entry := range entries {
129+
// Filter by person
130+
if personStr != "" {
131+
if !strings.Contains(strings.ToLower(entry.Creator.Name), strings.ToLower(personStr)) {
132+
continue
133+
}
134+
}
135+
136+
// Filter by date
137+
if !sinceDate.IsZero() {
138+
entryDate, err := time.Parse("2006-01-02", entry.Date)
139+
if err != nil || entryDate.Before(sinceDate) {
140+
continue
141+
}
142+
}
143+
144+
filtered = append(filtered, entry)
145+
}
146+
147+
return filtered
148+
}
149+
150+
// parseSince parses a "since" string (e.g., "7d", "24h", "2024-01-01")
151+
func parseSince(since string) (time.Time, error) {
152+
// Try parsing as duration (e.g., "7d", "24h")
153+
if strings.HasSuffix(since, "d") {
154+
days := since[:len(since)-1]
155+
d, err := strconv.Atoi(days)
156+
if err == nil {
157+
return time.Now().AddDate(0, 0, -d), nil
158+
}
159+
}
160+
if strings.HasSuffix(since, "h") {
161+
hours := since[:len(since)-1]
162+
h, err := strconv.Atoi(hours)
163+
if err == nil {
164+
return time.Now().Add(-time.Duration(h) * time.Hour), nil
165+
}
166+
}
167+
168+
// Try parsing as ISO date
169+
t, err := time.Parse("2006-01-02", since)
170+
if err != nil {
171+
return time.Time{}, fmt.Errorf("invalid date format: %s (expected Nd, Nh, or YYYY-MM-DD)", since)
172+
}
173+
return t, nil
174+
}
175+
176+
// outputJSON outputs entries as JSON
177+
func outputJSON(entries []api.TimesheetEntry) error {
178+
enc := json.NewEncoder(os.Stdout)
179+
enc.SetIndent("", " ")
180+
return enc.Encode(entries)
181+
}
182+
183+
// renderEntryTable displays entries in a table format, optionally with a total summary line
184+
func renderEntryTable(entries []api.TimesheetEntry, showTotal bool) error {
185+
if len(entries) == 0 {
186+
fmt.Println("No timesheet entries found")
187+
return nil
188+
}
189+
190+
tp := tableprinter.New(os.Stdout)
191+
192+
tp.AddField("DATE")
193+
tp.AddField("HOURS")
194+
tp.AddField("PERSON")
195+
tp.AddField("PROJECT")
196+
tp.AddField("PARENT")
197+
tp.AddField("DESCRIPTION")
198+
tp.EndRow()
199+
200+
var totalHours float64
201+
for _, entry := range entries {
202+
tp.AddField(entry.Date)
203+
tp.AddField(fmt.Sprintf("%.2f", entry.Hours))
204+
tp.AddField(entry.Creator.Name)
205+
tp.AddField(entry.Bucket.Name)
206+
tp.AddField(entry.Parent.Title)
207+
208+
desc := entry.Description
209+
if len(desc) > 50 {
210+
desc = desc[:47] + "..."
211+
}
212+
tp.AddField(desc)
213+
tp.EndRow()
214+
215+
totalHours += entry.Hours
216+
}
217+
218+
if err := tp.Render(); err != nil {
219+
return err
220+
}
221+
222+
if showTotal {
223+
fmt.Printf("\nTotal: %d entries, %.2f hours\n", len(entries), totalHours)
224+
}
225+
226+
return nil
227+
}

0 commit comments

Comments
 (0)