Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/.idea
/.claude/settings.local.json
/.goreleaser-tmp
/.idea
/bin
/dist
/vendor
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Always use mise to run build, lint, and test commands.
35 changes: 21 additions & 14 deletions cmd/list/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,17 @@ var (
flagDate = ""
flagOutput = "table"
cmdExample = `
# List all time entries
tl list`
# List all time entries for today
tl list

# List entries using human-friendly date keywords
tl list --date today
tl list --date yesterday
tl list --date "last week"
tl list --date "this month"

# List entries for a specific date
tl list --date 2026-01-15`
)

func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command {
Expand All @@ -33,19 +42,17 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command {
Example: cmdExample,
RunE: func(cmd *cobra.Command, args []string) error {

// Default to today's date if no date flag is provided
d := time.Now()
dateOutput := "today"
if flagDate != "" {
var err error
d, err = time.ParseInLocation(time.DateOnly, flagDate, time.Local)
if err != nil {
return fmt.Errorf("invalid d format: %s. Expected YYYY-MM-DD", flagDate)
}
dateOutput = flagDate
input := flagDate
if input == "" {
input = "today"
}

start, end, dateOutput, err := util.ParseHumanDate(input, time.Now())
if err != nil {
return err
}

entries, err := r().FindAllTimeEntries(d)
entries, err := r().FindTimeEntriesInRange(start, end)
if err != nil {
return err
}
Expand Down Expand Up @@ -134,7 +141,7 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command {
},
}

cmd.Flags().StringVarP(&flagDate, "date", "d", "", "List time entries created on a specific date (YYYY-MM-DD)")
cmd.Flags().StringVarP(&flagDate, "date", "d", "", "Date to list entries for (YYYY-MM-DD or 'today', 'yesterday', 'last week', 'this week', 'last month', 'this month')")
cmd.Flags().StringVarP(&flagOutput, "output", "o", flagOutput, "Output format (table,wide).")

return cmd
Expand Down
134 changes: 105 additions & 29 deletions cmd/list/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,38 @@ import (
"github.com/previousnext/tl-go/internal/model"
)

func twoEntries(start, end time.Time) ([]*model.TimeEntry, error) {
category1 := &model.Category{Model: gorm.Model{ID: 1}, Name: "Category1"}
category2 := &model.Category{Model: gorm.Model{ID: 2}, Name: "Category2"}
project1 := model.Project{Name: "Project1", Category: category1}
project2 := model.Project{Name: "Project2", Category: category2}
return []*model.TimeEntry{
{
Model: gorm.Model{ID: 1},
IssueKey: "PNX-1",
Issue: &model.Issue{
Summary: "issue1",
Project: project1,
},
Duration: 2 * time.Hour,
Description: "Worked on X",
},
{
Model: gorm.Model{ID: 2},
IssueKey: "PNX-2",
Issue: &model.Issue{
Summary: "issue2",
Project: project2,
},
Duration: 30 * time.Minute,
Description: "Reviewed Y",
},
}, nil
}

func TestNewCommand_PrintsEntriesInTable(t *testing.T) {
mock := &mocks.MockRepository{
FindAllTimeEntriesFunc: func(date time.Time) ([]*model.TimeEntry, error) {
category1 := &model.Category{Model: gorm.Model{ID: 1}, Name: "Category1"}
category2 := &model.Category{Model: gorm.Model{ID: 2}, Name: "Category2"}
project1 := model.Project{Name: "Project1", Category: category1}
project2 := model.Project{Name: "Project2", Category: category2}
return []*model.TimeEntry{
{
Model: gorm.Model{ID: 1},
IssueKey: "PNX-1",
Issue: &model.Issue{
Summary: "issue1",
Project: project1,
},
Duration: 2 * time.Hour,
Description: "Worked on X",
},
{
Model: gorm.Model{ID: 2},
IssueKey: "PNX-2",
Issue: &model.Issue{
Summary: "issue2",
Project: project2,
},
Duration: 30 * time.Minute,
Description: "Reviewed Y",
},
}, nil
},
FindTimeEntriesInRangeFunc: twoEntries,
}
cmd := NewCommand(func() db.TimeEntriesInterface { return mock })
cmd.SetArgs([]string{"--output=wide"})
Expand All @@ -67,7 +69,7 @@ func TestNewCommand_PrintsEntriesInTable(t *testing.T) {

func TestNewCommand_PrintsEntriesWithNilCategory(t *testing.T) {
mock := &mocks.MockRepository{
FindAllTimeEntriesFunc: func(date time.Time) ([]*model.TimeEntry, error) {
FindTimeEntriesInRangeFunc: func(start, end time.Time) ([]*model.TimeEntry, error) {
project1 := model.Project{Name: "Project1", Category: nil}
project2 := model.Project{Name: "Project2", Category: nil}
return []*model.TimeEntry{
Expand Down Expand Up @@ -112,3 +114,77 @@ func TestNewCommand_PrintsEntriesWithNilCategory(t *testing.T) {
assert.Contains(t, output, "2h")
assert.Contains(t, output, "30m")
}

func TestNewCommand_HumanDateKeywords(t *testing.T) {
tests := []struct {
dateFlag string
wantLabel string
}{
{dateFlag: "today", wantLabel: "today"},
{dateFlag: "yesterday", wantLabel: "yesterday"},
{dateFlag: "last week", wantLabel: "last week"},
{dateFlag: "this week", wantLabel: "this week"},
{dateFlag: "last month", wantLabel: "last month"},
{dateFlag: "this month", wantLabel: "this month"},
{dateFlag: "2026-01-15", wantLabel: "2026-01-15"},
}

for _, tt := range tests {
t.Run(tt.dateFlag, func(t *testing.T) {
mock := &mocks.MockRepository{
FindTimeEntriesInRangeFunc: twoEntries,
}
cmd := NewCommand(func() db.TimeEntriesInterface { return mock })
cmd.SetArgs([]string{"--date", tt.dateFlag})

var buf bytes.Buffer
cmd.SetOut(&buf)

err := cmd.Execute()
assert.NoError(t, err)
assert.Contains(t, buf.String(), tt.wantLabel)
})
}
}

func TestNewCommand_DefaultDateIsToday(t *testing.T) {
mock := &mocks.MockRepository{
FindTimeEntriesInRangeFunc: twoEntries,
}
cmd := NewCommand(func() db.TimeEntriesInterface { return mock })

var buf bytes.Buffer
cmd.SetOut(&buf)

err := cmd.Execute()
assert.NoError(t, err)
assert.Contains(t, buf.String(), "today")
}

func TestNewCommand_InvalidDate(t *testing.T) {
mock := &mocks.MockRepository{}
cmd := NewCommand(func() db.TimeEntriesInterface { return mock })
cmd.SetArgs([]string{"--date", "not-a-date"})

var buf bytes.Buffer
cmd.SetOut(&buf)

err := cmd.Execute()
assert.Error(t, err)
}

func TestNewCommand_NoEntries(t *testing.T) {
mock := &mocks.MockRepository{
FindTimeEntriesInRangeFunc: func(start, end time.Time) ([]*model.TimeEntry, error) {
return []*model.TimeEntry{}, nil
},
}
cmd := NewCommand(func() db.TimeEntriesInterface { return mock })

var buf bytes.Buffer
cmd.SetOut(&buf)

err := cmd.Execute()
assert.NoError(t, err)
assert.Contains(t, buf.String(), "No time entries found")
}
74 changes: 46 additions & 28 deletions cmd/summary/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ import (

var (
cmdShort = "Show a summary of time spent per project category"
cmdLong = "Show a summary of time spent per project category for the current week."
cmdLong = "Show a summary of time spent per project category for a given period."
cmdExample = `
# Show a summary of time spent per project category for the current week
tl summary`
# Show a summary for the current week (default)
tl summary

# Show a summary using human-friendly date keywords
tl summary --date "last week"
tl summary --date "this month"
tl summary --date yesterday

# Show a summary for a specific date range
tl summary --start 2026-01-01 --end 2026-01-31`
flagDate = ""
flagStart = ""
flagEnd = ""
)
Expand All @@ -33,32 +42,42 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command {
Example: cmdExample,
RunE: func(cmd *cobra.Command, args []string) error {
var start, end time.Time
var label string
var err error
if flagStart == "" {
// Default to the start of the current week if no start date is provided
start = startOfCurrentWeek()
} else {
start, err = time.ParseInLocation("2006-01-02", flagStart, time.Local)

if flagDate != "" {
start, end, label, err = util.ParseHumanDate(flagDate, time.Now())
if err != nil {
return err
}
}
// Set start to midnight
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())

if flagEnd == "" {
// Default to 7 days after the start date if no end date is provided
end = start.AddDate(0, 0, 7)
} else {
end, err = time.ParseInLocation("2006-01-02", flagEnd, time.Local)
if err != nil {
return err
// Resolve --start
if flagStart == "" {
start, end, label, _ = util.ParseHumanDate("this week", time.Now())
} else {
start, err = time.ParseInLocation("2006-01-02", flagStart, time.Local)
if err != nil {
return err
}
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
}

// Resolve --end (only when not already set by the this-week default)
if flagStart != "" || flagEnd != "" {
if flagEnd == "" {
// Default to a 7-day inclusive window starting at --start
end = start.AddDate(0, 0, 6)
} else {
end, err = time.ParseInLocation("2006-01-02", flagEnd, time.Local)
if err != nil {
return err
}
}
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, end.Location())
Comment thread
kimpepper marked this conversation as resolved.
label = fmt.Sprintf("%s to %s", start.Format("2006-01-02"), end.Format("2006-01-02"))
}
}
// Set end to end of day to include the entire end date
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, end.Location())

// Validate start is before or equal to end
if start.After(end) {
return fmt.Errorf("start date (%s) must be before or equal to end date (%s)", start.Format("2006-01-02"), end.Format("2006-01-02"))
}
Expand Down Expand Up @@ -97,19 +116,18 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command {
t.SetFooterAlignment(table.AlignRight, table.AlignLeft, table.AlignLeft)

t.Render()
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Summary of time spent per category from %s to %s:\n", start.Format("2006-01-02"), end.Format("2006-01-02"))
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Summary of time spent per category from %s:\n", label)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", b.String())
return nil
},
}

cmd.Flags().StringVar(&flagDate, "date", "", "Date range keyword ('today', 'yesterday', 'this week', 'last week', 'this month', 'last month') or YYYY-MM-DD")
cmd.Flags().StringVar(&flagStart, "start", "", "Start date (YYYY-MM-DD)")
cmd.Flags().StringVar(&flagEnd, "end", "", "End date (YYYY-MM-DD)")
return cmd
}

func startOfCurrentWeek() time.Time {
now := time.Now().In(time.Local)
daysToMonday := (int(now.Weekday()) + 6) % 7
return now.AddDate(0, 0, -daysToMonday)
cmd.MarkFlagsMutuallyExclusive("date", "start")
cmd.MarkFlagsMutuallyExclusive("date", "end")

return cmd
}
Loading