From a7ccd27f5539008a6b5401123722df8688e67afe Mon Sep 17 00:00:00 2001 From: Kim Pepper Date: Wed, 18 Mar 2026 15:07:37 +1100 Subject: [PATCH 1/2] feat: Add human-friendly date parsing to list and summary commands Adds support for date keywords ('today', 'yesterday', 'this week', 'last week', 'this month', 'last month') and YYYY-MM-DD strings to the --date flag on tl list and tl summary. - Introduces util.ParseHumanDate backed by the already-vendored jinzhu/now library (week starts Monday) - Replaces FindAllTimeEntries with FindTimeEntriesInRange on the DB interface, allowing date-range queries for week/month keywords - Adds --date flag to tl summary; mutually exclusive with --start/--end - Removes the hand-rolled startOfCurrentWeek() helper in favour of ParseHumanDate("this week", ...) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +- CLAUDE.md | 1 + cmd/list/command.go | 35 ++++--- cmd/list/command_test.go | 134 ++++++++++++++++++------ cmd/summary/command.go | 73 ++++++++----- cmd/summary/command_test.go | 150 +++++++++++++++++++++++---- go.mod | 2 +- internal/db/mocks/mock_repository.go | 18 ++-- internal/db/repository.go | 6 -- internal/db/time_entries.go | 5 +- internal/service/timer_entry_test.go | 2 +- internal/util/dateparse.go | 59 +++++++++++ internal/util/dateparse_test.go | 112 ++++++++++++++++++++ 13 files changed, 488 insertions(+), 112 deletions(-) create mode 100644 CLAUDE.md create mode 100644 internal/util/dateparse.go create mode 100644 internal/util/dateparse_test.go diff --git a/.gitignore b/.gitignore index 66cd4f3..3169f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -/.idea +/.claude/settings.local.json /.goreleaser-tmp +/.idea /bin /dist /vendor diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5648071 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +Always use mise to run build, lint, and test commands. diff --git a/cmd/list/command.go b/cmd/list/command.go index fb172ba..225db52 100644 --- a/cmd/list/command.go +++ b/cmd/list/command.go @@ -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 { @@ -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 } @@ -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 diff --git a/cmd/list/command_test.go b/cmd/list/command_test.go index bf28bfa..3dcfb02 100644 --- a/cmd/list/command_test.go +++ b/cmd/list/command_test.go @@ -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"}) @@ -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{ @@ -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") +} diff --git a/cmd/summary/command.go b/cmd/summary/command.go index 1db0795..c3423d0 100644 --- a/cmd/summary/command.go +++ b/cmd/summary/command.go @@ -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 = "" ) @@ -33,32 +42,41 @@ 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 == "" { + end = start.AddDate(0, 0, 7) + } 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()) + 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")) } @@ -97,19 +115,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 } diff --git a/cmd/summary/command_test.go b/cmd/summary/command_test.go index 1e6dd14..820cf62 100644 --- a/cmd/summary/command_test.go +++ b/cmd/summary/command_test.go @@ -11,15 +11,21 @@ import ( "github.com/previousnext/tl-go/internal/db/mocks" ) -func TestSummaryCommand_PrintsTable(t *testing.T) { +func summaryMock(fn func(start, end time.Time) ([]db.CategorySummary, error)) *mocks.MockRepository { mock := &mocks.MockRepository{} - mock.GetSummaryByCategoryFunc = func(start, end time.Time) ([]db.CategorySummary, error) { - return []db.CategorySummary{ - {CategoryName: "Billable", Duration: 2 * time.Hour, Percentage: 66.7}, - {CategoryName: "Non Billable", Duration: 1 * time.Hour, Percentage: 33.3}, - }, nil - } - cmd := NewCommand(func() db.TimeEntriesInterface { return mock }) + mock.GetSummaryByCategoryFunc = fn + return mock +} + +func twoCategories(start, end time.Time) ([]db.CategorySummary, error) { + return []db.CategorySummary{ + {CategoryName: "Billable", Duration: 2 * time.Hour, Percentage: 66.7}, + {CategoryName: "Non Billable", Duration: 1 * time.Hour, Percentage: 33.3}, + }, nil +} + +func TestSummaryCommand_PrintsTable(t *testing.T) { + cmd := NewCommand(func() db.TimeEntriesInterface { return summaryMock(twoCategories) }) var buf bytes.Buffer cmd.SetOut(&buf) @@ -28,38 +34,142 @@ func TestSummaryCommand_PrintsTable(t *testing.T) { assert.NoError(t, err) output := buf.String() - // Check for table headers assert.Contains(t, output, "Category") assert.Contains(t, output, "Total Time") assert.Contains(t, output, "Percentage") - // Check for category rows assert.Contains(t, output, "Billable") assert.Contains(t, output, "Non Billable") - // Check for formatted durations assert.Contains(t, output, "2h") assert.Contains(t, output, "1h") - // Check for percentages assert.Contains(t, output, "66.7%") assert.Contains(t, output, "33.3%") - // Check for total assert.Contains(t, output, "Total") assert.Contains(t, output, "3h") - // Check for summary line assert.Contains(t, output, "Summary of time spent per category") } func TestSummaryCommand_NoResults(t *testing.T) { - mock := &mocks.MockRepository{} - mock.GetSummaryByCategoryFunc = func(start, end time.Time) ([]db.CategorySummary, error) { - return nil, nil + cmd := NewCommand(func() db.TimeEntriesInterface { + return summaryMock(func(start, end time.Time) ([]db.CategorySummary, error) { + return nil, nil + }) + }) + + var buf bytes.Buffer + cmd.SetOut(&buf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Contains(t, buf.String(), "No time entries found for the period") +} + +func TestSummaryCommand_DateKeywords(t *testing.T) { + keywords := []string{ + "today", + "yesterday", + "this week", + "last week", + "this month", + "last month", } - cmd := NewCommand(func() db.TimeEntriesInterface { return mock }) + + for _, kw := range keywords { + t.Run(kw, func(t *testing.T) { + var capturedStart, capturedEnd time.Time + cmd := NewCommand(func() db.TimeEntriesInterface { + return summaryMock(func(start, end time.Time) ([]db.CategorySummary, error) { + capturedStart = start + capturedEnd = end + return twoCategories(start, end) + }) + }) + cmd.SetArgs([]string{"--date", kw}) + + var buf bytes.Buffer + cmd.SetOut(&buf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.False(t, capturedStart.IsZero(), "start should be set") + assert.False(t, capturedEnd.IsZero(), "end should be set") + assert.True(t, capturedStart.Before(capturedEnd), "start should be before end") + assert.Contains(t, buf.String(), kw) + }) + } +} + +func TestSummaryCommand_DateFlag_YYYYMMDD(t *testing.T) { + var capturedStart, capturedEnd time.Time + cmd := NewCommand(func() db.TimeEntriesInterface { + return summaryMock(func(start, end time.Time) ([]db.CategorySummary, error) { + capturedStart = start + capturedEnd = end + return twoCategories(start, end) + }) + }) + cmd.SetArgs([]string{"--date", "2026-01-15"}) var buf bytes.Buffer cmd.SetOut(&buf) err := cmd.Execute() assert.NoError(t, err) - output := buf.String() - assert.Contains(t, output, "No time entries found for the period") + assert.Equal(t, 2026, capturedStart.Year()) + assert.Equal(t, time.January, capturedStart.Month()) + assert.Equal(t, 15, capturedStart.Day()) + assert.Equal(t, 15, capturedEnd.Day()) +} + +func TestSummaryCommand_DateFlag_InvalidDate(t *testing.T) { + cmd := NewCommand(func() db.TimeEntriesInterface { return summaryMock(twoCategories) }) + cmd.SetArgs([]string{"--date", "not-a-date"}) + + var buf bytes.Buffer + cmd.SetOut(&buf) + + err := cmd.Execute() + assert.Error(t, err) +} + +func TestSummaryCommand_DateFlag_ConflictsWithStart(t *testing.T) { + cmd := NewCommand(func() db.TimeEntriesInterface { return summaryMock(twoCategories) }) + cmd.SetArgs([]string{"--date", "today", "--start", "2026-01-01"}) + + var buf bytes.Buffer + cmd.SetOut(&buf) + + err := cmd.Execute() + assert.Error(t, err) +} + +func TestSummaryCommand_DateFlag_ConflictsWithEnd(t *testing.T) { + cmd := NewCommand(func() db.TimeEntriesInterface { return summaryMock(twoCategories) }) + cmd.SetArgs([]string{"--date", "today", "--end", "2026-01-31"}) + + var buf bytes.Buffer + cmd.SetOut(&buf) + + err := cmd.Execute() + assert.Error(t, err) +} + +func TestSummaryCommand_StartAndEndFlags(t *testing.T) { + var capturedStart, capturedEnd time.Time + cmd := NewCommand(func() db.TimeEntriesInterface { + return summaryMock(func(start, end time.Time) ([]db.CategorySummary, error) { + capturedStart = start + capturedEnd = end + return twoCategories(start, end) + }) + }) + cmd.SetArgs([]string{"--start", "2026-01-01", "--end", "2026-01-31"}) + + var buf bytes.Buffer + cmd.SetOut(&buf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, 1, capturedStart.Day()) + assert.Equal(t, time.January, capturedStart.Month()) + assert.Equal(t, 31, capturedEnd.Day()) } diff --git a/go.mod b/go.mod index 42d183a..ff88b88 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/aquasecurity/table v1.11.0 github.com/charmbracelet/fang v1.0.0 github.com/glebarez/sqlite v1.11.0 + github.com/jinzhu/now v1.1.5 github.com/jwalton/gchalk v1.3.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -35,7 +36,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect github.com/jwalton/go-supportscolor v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/internal/db/mocks/mock_repository.go b/internal/db/mocks/mock_repository.go index f314ece..230c143 100644 --- a/internal/db/mocks/mock_repository.go +++ b/internal/db/mocks/mock_repository.go @@ -11,12 +11,12 @@ import ( type MockRepository struct { db.TimeEntriesInterface db.IssueStorageInterface - Entries []*model.TimeEntry - FindAllTimeEntriesFunc func(date time.Time) ([]*model.TimeEntry, error) - FindUnsentTimeEntriesFunc func() ([]*model.TimeEntry, error) - FindTimeEntryFunc func(id uint) (*model.TimeEntry, error) - UpdateTimeEntryFunc func(entry *model.TimeEntry) error - GetSummaryByCategoryFunc func(start, end time.Time) ([]db.CategorySummary, error) + Entries []*model.TimeEntry + FindTimeEntriesInRangeFunc func(start, end time.Time) ([]*model.TimeEntry, error) + FindUnsentTimeEntriesFunc func() ([]*model.TimeEntry, error) + FindTimeEntryFunc func(id uint) (*model.TimeEntry, error) + UpdateTimeEntryFunc func(entry *model.TimeEntry) error + GetSummaryByCategoryFunc func(start, end time.Time) ([]db.CategorySummary, error) } var _ db.IssueStorageInterface = (*MockRepository)(nil) @@ -39,9 +39,9 @@ func (m *MockRepository) FindTimeEntry(id uint) (*model.TimeEntry, error) { } return nil, nil } -func (m *MockRepository) FindAllTimeEntries(date time.Time) ([]*model.TimeEntry, error) { - if m.FindAllTimeEntriesFunc != nil { - return m.FindAllTimeEntriesFunc(time.Now()) +func (m *MockRepository) FindTimeEntriesInRange(start, end time.Time) ([]*model.TimeEntry, error) { + if m.FindTimeEntriesInRangeFunc != nil { + return m.FindTimeEntriesInRangeFunc(start, end) } return m.Entries, nil } diff --git a/internal/db/repository.go b/internal/db/repository.go index b8504f9..1077772 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -4,7 +4,6 @@ import ( "fmt" "log" "os" - "time" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -56,8 +55,3 @@ func (r *Repository) openDB() *gorm.DB { return db } -func getStartAndEndOfDay(date time.Time) (time.Time, time.Time) { - startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - endOfDay := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, date.Location()) - return startOfDay, endOfDay -} diff --git a/internal/db/time_entries.go b/internal/db/time_entries.go index 0e125e9..0b10241 100644 --- a/internal/db/time_entries.go +++ b/internal/db/time_entries.go @@ -9,7 +9,7 @@ import ( type TimeEntriesInterface interface { CreateTimeEntry(entry *model.TimeEntry) error FindTimeEntry(id uint) (*model.TimeEntry, error) - FindAllTimeEntries(date time.Time) ([]*model.TimeEntry, error) + FindTimeEntriesInRange(start, end time.Time) ([]*model.TimeEntry, error) FindUnsentTimeEntries() ([]*model.TimeEntry, error) FindUniqueIssueKeys() ([]string, error) UpdateTimeEntry(entry *model.TimeEntry) error @@ -40,8 +40,7 @@ func (r *Repository) FindTimeEntry(id uint) (*model.TimeEntry, error) { return &entry, nil } -func (r *Repository) FindAllTimeEntries(date time.Time) ([]*model.TimeEntry, error) { - start, end := getStartAndEndOfDay(date) +func (r *Repository) FindTimeEntriesInRange(start, end time.Time) ([]*model.TimeEntry, error) { db := r.openDB() var entries []*model.TimeEntry if err := db.Preload("Issue.Project.Category").Where("created_at BETWEEN ? AND ?", start, end).Find(&entries).Error; err != nil { diff --git a/internal/service/timer_entry_test.go b/internal/service/timer_entry_test.go index 19ebe42..a393668 100644 --- a/internal/service/timer_entry_test.go +++ b/internal/service/timer_entry_test.go @@ -79,7 +79,7 @@ func (m *mockTimeEntriesStorage) CreateTimeEntry(entry *model.TimeEntry) error { } func (m *mockTimeEntriesStorage) FindTimeEntry(id uint) (*model.TimeEntry, error) { return nil, nil } -func (m *mockTimeEntriesStorage) FindAllTimeEntries(date time.Time) ([]*model.TimeEntry, error) { +func (m *mockTimeEntriesStorage) FindTimeEntriesInRange(start, end time.Time) ([]*model.TimeEntry, error) { return nil, nil } func (m *mockTimeEntriesStorage) FindUnsentTimeEntries() ([]*model.TimeEntry, error) { return nil, nil } diff --git a/internal/util/dateparse.go b/internal/util/dateparse.go new file mode 100644 index 0000000..f5db3d2 --- /dev/null +++ b/internal/util/dateparse.go @@ -0,0 +1,59 @@ +package util + +import ( + "fmt" + "strings" + "time" + + "github.com/jinzhu/now" +) + +var mondayConfig = &now.Config{WeekStartDay: time.Monday} + +// ParseHumanDate parses a human-friendly date string relative to ref. +// Supported keywords: today, yesterday, this week, last week, this month, last month. +// Falls back to strict YYYY-MM-DD parsing. +// Returns the resolved start/end times, a display label, and any error. +func ParseHumanDate(s string, ref time.Time) (start, end time.Time, label string, err error) { + n := mondayConfig.With(ref) + + switch strings.ToLower(strings.TrimSpace(s)) { + case "today": + start = n.BeginningOfDay() + end = n.EndOfDay() + label = "today" + case "yesterday": + yesterday := mondayConfig.With(ref.AddDate(0, 0, -1)) + start = yesterday.BeginningOfDay() + end = yesterday.EndOfDay() + label = "yesterday" + case "this week": + start = n.BeginningOfWeek() + end = n.EndOfWeek() + label = "this week" + case "last week": + lastWeek := mondayConfig.With(ref.AddDate(0, 0, -7)) + start = lastWeek.BeginningOfWeek() + end = lastWeek.EndOfWeek() + label = "last week" + case "this month": + start = n.BeginningOfMonth() + end = n.EndOfMonth() + label = "this month" + case "last month": + lastMonth := mondayConfig.With(ref.AddDate(0, -1, 0)) + start = lastMonth.BeginningOfMonth() + end = lastMonth.EndOfMonth() + label = "last month" + default: + t, parseErr := time.ParseInLocation(time.DateOnly, strings.TrimSpace(s), ref.Location()) + if parseErr != nil { + return time.Time{}, time.Time{}, "", fmt.Errorf("unrecognised date %q: expected YYYY-MM-DD or a keyword like 'today', 'yesterday', 'last week'", s) + } + d := mondayConfig.With(t) + start = d.BeginningOfDay() + end = d.EndOfDay() + label = t.Format(time.DateOnly) + } + return start, end, label, nil +} diff --git a/internal/util/dateparse_test.go b/internal/util/dateparse_test.go new file mode 100644 index 0000000..889f39e --- /dev/null +++ b/internal/util/dateparse_test.go @@ -0,0 +1,112 @@ +package util + +import ( + "testing" + "time" +) + +// ref is a fixed Wednesday 2026-03-18 12:00:00 UTC for deterministic tests. +var ref = time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) + +func TestParseHumanDate(t *testing.T) { + tests := []struct { + input string + wantStart time.Time + wantEnd time.Time + wantLabel string + wantErr bool + }{ + { + input: "today", + wantStart: time.Date(2026, 3, 18, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 3, 18, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC), + wantLabel: "today", + }, + { + input: "TODAY", + wantStart: time.Date(2026, 3, 18, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 3, 18, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC), + wantLabel: "today", + }, + { + input: "yesterday", + wantStart: time.Date(2026, 3, 17, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 3, 17, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC), + wantLabel: "yesterday", + }, + { + // ref is Wednesday 2026-03-18; week starts Monday, so this week = Mon 2026-03-16 + input: "this week", + wantStart: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 3, 22, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC), + wantLabel: "this week", + }, + { + // last week = Mon 2026-03-09 to Sun 2026-03-15 + input: "last week", + wantStart: time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 3, 15, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC), + wantLabel: "last week", + }, + { + input: "Last Week", + wantStart: time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 3, 15, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC), + wantLabel: "last week", + }, + { + // ref is in March 2026; this month = 2026-03-01 to 2026-03-31 + input: "this month", + wantStart: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 3, 31, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC), + wantLabel: "this month", + }, + { + // last month = 2026-02-01 to 2026-02-28 + input: "last month", + wantStart: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 2, 28, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC), + wantLabel: "last month", + }, + { + // YYYY-MM-DD fallback + input: "2026-01-15", + wantStart: time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2026, 1, 15, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC), + wantLabel: "2026-01-15", + }, + { + input: "not-a-date", + wantErr: true, + }, + { + input: "2026-99-99", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + start, end, label, err := ParseHumanDate(tt.input, ref) + + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !start.Equal(tt.wantStart) { + t.Errorf("start: got %v, want %v", start, tt.wantStart) + } + if !end.Equal(tt.wantEnd) { + t.Errorf("end: got %v, want %v", end, tt.wantEnd) + } + if label != tt.wantLabel { + t.Errorf("label: got %q, want %q", label, tt.wantLabel) + } + }) + } +} From 801edbe82c681d6df6788b4c7d0c84173a09e2d2 Mon Sep 17 00:00:00 2001 From: Kim Pepper Date: Wed, 18 Mar 2026 16:10:57 +1100 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cmd/summary/command.go | 3 ++- internal/util/dateparse.go | 6 +++++- internal/util/dateparse_test.go | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/cmd/summary/command.go b/cmd/summary/command.go index c3423d0..bf5aab8 100644 --- a/cmd/summary/command.go +++ b/cmd/summary/command.go @@ -65,7 +65,8 @@ func NewCommand(r func() db.TimeEntriesInterface) *cobra.Command { // Resolve --end (only when not already set by the this-week default) if flagStart != "" || flagEnd != "" { if flagEnd == "" { - end = start.AddDate(0, 0, 7) + // 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 { diff --git a/internal/util/dateparse.go b/internal/util/dateparse.go index f5db3d2..451d807 100644 --- a/internal/util/dateparse.go +++ b/internal/util/dateparse.go @@ -41,7 +41,11 @@ func ParseHumanDate(s string, ref time.Time) (start, end time.Time, label string end = n.EndOfMonth() label = "this month" case "last month": - lastMonth := mondayConfig.With(ref.AddDate(0, -1, 0)) + // Derive last month from the beginning of the current month to avoid + // AddDate day overflow issues on end-of-month reference dates. + currentMonthStart := n.BeginningOfMonth() + prevMonthRef := currentMonthStart.AddDate(0, 0, -1) + lastMonth := mondayConfig.With(prevMonthRef) start = lastMonth.BeginningOfMonth() end = lastMonth.EndOfMonth() label = "last month" diff --git a/internal/util/dateparse_test.go b/internal/util/dateparse_test.go index 889f39e..020fb20 100644 --- a/internal/util/dateparse_test.go +++ b/internal/util/dateparse_test.go @@ -110,3 +110,21 @@ func TestParseHumanDate(t *testing.T) { }) } } + +func TestParseHumanDate_LastMonth_EndOfMonthRef(t *testing.T) { + endOfMonthRef := time.Date(2026, 3, 31, 12, 0, 0, 0, time.UTC) + + gotStart, gotEnd, gotLabel, err := ParseHumanDate("last month", endOfMonthRef) + if err != nil { + t.Fatalf("ParseHumanDate returned error: %v", err) + } + + wantStart := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + wantEnd := time.Date(2026, 2, 28, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC) + wantLabel := "last month" + + if !gotStart.Equal(wantStart) || !gotEnd.Equal(wantEnd) || gotLabel != wantLabel { + t.Fatalf("ParseHumanDate(%q, %v) = (%v, %v, %q), want (%v, %v, %q)", + "last month", endOfMonthRef, gotStart, gotEnd, gotLabel, wantStart, wantEnd, wantLabel) + } +}