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

Commit a98fd52

Browse files
feat: add timesheet command for viewing time entries
Implements read-only timesheet functionality with list and report commands: - Add timesheet data models and API client methods - Implement 'bc4 timesheet list' for project-specific entries - Implement 'bc4 timesheet report' for account-wide analysis - Support filtering by person, date range, and recording - Add grouping options (by person/project) with aggregated totals - Support JSON output format alongside table rendering Note: The Basecamp API only provides GET endpoints for timesheets. Creating/updating entries must be done through the web interface. Resolves #138
1 parent cda5308 commit a98fd52

7 files changed

Lines changed: 710 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: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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+
// Get API client from factory
67+
client, err := f.ApiClient()
68+
if err != nil {
69+
return err
70+
}
71+
72+
// Get resolved project ID
73+
resolvedProjectID, err := f.ProjectID()
74+
if err != nil {
75+
return err
76+
}
77+
78+
// Fetch timesheet entries
79+
var entries []api.TimesheetEntry
80+
if recordingID > 0 {
81+
// Get entries for a specific recording
82+
entries, err = client.GetRecordingTimesheet(cmd.Context(), resolvedProjectID, recordingID)
83+
} else {
84+
// Get entries for the project
85+
entries, err = client.GetProjectTimesheet(cmd.Context(), resolvedProjectID)
86+
}
87+
if err != nil {
88+
return err
89+
}
90+
91+
// Apply filters
92+
entries = filterEntries(entries, personStr, sinceStr)
93+
94+
// Output format
95+
if formatStr == "json" {
96+
return outputJSON(entries)
97+
}
98+
99+
// Display as table
100+
return displayTable(entries)
101+
},
102+
}
103+
104+
cmd.Flags().StringVarP(&accountID, "account", "a", "", "Account ID")
105+
cmd.Flags().StringVarP(&projectID, "project", "p", "", "Project ID")
106+
cmd.Flags().StringVar(&personStr, "person", "", "Filter by person name (case-insensitive substring match)")
107+
cmd.Flags().StringVar(&sinceStr, "since", "", "Show entries since (e.g., '7d', '2024-01-01')")
108+
cmd.Flags().StringVarP(&formatStr, "format", "f", "table", "Output format (table, json)")
109+
cmd.Flags().Int64Var(&recordingID, "recording", 0, "Filter by recording ID")
110+
111+
return cmd
112+
}
113+
114+
// filterEntries applies person and date filters
115+
func filterEntries(entries []api.TimesheetEntry, personStr, sinceStr string) []api.TimesheetEntry {
116+
var filtered []api.TimesheetEntry
117+
118+
// Parse since filter
119+
var sinceDate time.Time
120+
if sinceStr != "" {
121+
since, err := parseSince(sinceStr)
122+
if err == nil {
123+
sinceDate = since
124+
}
125+
}
126+
127+
for _, entry := range entries {
128+
// Filter by person
129+
if personStr != "" {
130+
if !strings.Contains(strings.ToLower(entry.Creator.Name), strings.ToLower(personStr)) {
131+
continue
132+
}
133+
}
134+
135+
// Filter by date
136+
if !sinceDate.IsZero() {
137+
entryDate, err := time.Parse("2006-01-02", entry.Date)
138+
if err != nil || entryDate.Before(sinceDate) {
139+
continue
140+
}
141+
}
142+
143+
filtered = append(filtered, entry)
144+
}
145+
146+
return filtered
147+
}
148+
149+
// parseSince parses a "since" string (e.g., "7d", "2024-01-01")
150+
func parseSince(since string) (time.Time, error) {
151+
// Try parsing as duration (e.g., "7d", "24h")
152+
if strings.HasSuffix(since, "d") {
153+
days := since[:len(since)-1]
154+
d, err := strconv.Atoi(days)
155+
if err == nil {
156+
return time.Now().AddDate(0, 0, -d), nil
157+
}
158+
}
159+
if strings.HasSuffix(since, "h") {
160+
hours := since[:len(since)-1]
161+
h, err := strconv.Atoi(hours)
162+
if err == nil {
163+
return time.Now().Add(-time.Duration(h) * time.Hour), nil
164+
}
165+
}
166+
167+
// Try parsing as ISO date
168+
t, err := time.Parse("2006-01-02", since)
169+
if err != nil {
170+
return time.Time{}, fmt.Errorf("invalid date format: %s", since)
171+
}
172+
return t, nil
173+
}
174+
175+
// outputJSON outputs entries as JSON
176+
func outputJSON(entries []api.TimesheetEntry) error {
177+
enc := json.NewEncoder(os.Stdout)
178+
enc.SetIndent("", " ")
179+
return enc.Encode(entries)
180+
}
181+
182+
// displayTable displays entries in a table format
183+
func displayTable(entries []api.TimesheetEntry) error {
184+
if len(entries) == 0 {
185+
fmt.Println("No timesheet entries found")
186+
return nil
187+
}
188+
189+
// Create table
190+
tp := tableprinter.New(os.Stdout)
191+
192+
// Add headers
193+
tp.AddField("DATE")
194+
tp.AddField("HOURS")
195+
tp.AddField("PERSON")
196+
tp.AddField("PROJECT")
197+
tp.AddField("PARENT")
198+
tp.AddField("DESCRIPTION")
199+
tp.EndRow()
200+
201+
// Add rows
202+
for _, entry := range entries {
203+
tp.AddField(entry.Date)
204+
tp.AddField(fmt.Sprintf("%.2f", entry.Hours))
205+
tp.AddField(entry.Creator.Name)
206+
tp.AddField(entry.Bucket.Name)
207+
tp.AddField(entry.Parent.Title)
208+
209+
// Truncate description if too long
210+
desc := entry.Description
211+
if len(desc) > 50 {
212+
desc = desc[:47] + "..."
213+
}
214+
tp.AddField(desc)
215+
tp.EndRow()
216+
}
217+
218+
return tp.Render()
219+
}

0 commit comments

Comments
 (0)