diff --git a/CHANGELOG.md b/CHANGELOG.md index 286c855e..d249c52d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- support to limit how many time entries should be listed on the `report` commands, and choose which page to + show + ## [v0.54.2] - 2025-06-25 ### Fixed diff --git a/pkg/cmd/time-entry/report/today/today_test.go b/pkg/cmd/time-entry/report/today/today_test.go index 05a2578b..b0feed4e 100644 --- a/pkg/cmd/time-entry/report/today/today_test.go +++ b/pkg/cmd/time-entry/report/today/today_test.go @@ -193,6 +193,45 @@ func TestCmdToday(t *testing.T) { time-entry-2 `), }, + { + name: "report only the first time entry", + args: "--limit 2 --page 10 -q", + factory: func(t *testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + f.On("GetUserID").Return("user-id", nil) + f.On("GetWorkspaceID").Return("w-id", nil) + + f.On("Config").Return(&mocks.SimpleConfig{}) + + c := mocks.NewMockClient(t) + f.On("Client").Return(c, nil) + + c.On("LogRange", api.LogRangeParam{ + Workspace: "w-id", + UserID: "user-id", + FirstDate: first, + LastDate: last, + TagIDs: []string{}, + PaginationParam: api.PaginationParam{ + Page: 10, + PageSize: 2, + }, + }). + Return( + []dto.TimeEntry{ + {ID: "time-entry-1"}, + {ID: "time-entry-2"}, + }, + nil, + ) + + return f + }, + expected: heredoc.Doc(` + time-entry-1 + time-entry-2 + `), + }, } for i := range tts { diff --git a/pkg/cmd/time-entry/report/util/report.go b/pkg/cmd/time-entry/report/util/report.go index 2ae34c50..b103aa15 100644 --- a/pkg/cmd/time-entry/report/util/report.go +++ b/pkg/cmd/time-entry/report/util/report.go @@ -1,6 +1,7 @@ package util import ( + "errors" "io" "sort" "time" @@ -27,6 +28,8 @@ type ReportFlags struct { util.OutputFlags FillMissingDates bool + Limit int + Page int Billable bool NotBillable bool @@ -43,6 +46,18 @@ func (rf ReportFlags) Check() error { return err } + if rf.Page > 0 && rf.Limit <= 0 { + return cmdutil.FlagErrorWrap( + errors.New("page can't be used without limit")) + } + + if err := cmdutil.XorFlag(map[string]bool{ + "limit": rf.Limit > 0, + "fill-missing-dates": rf.FillMissingDates, + }); err != nil { + return err + } + return cmdutil.XorFlag(map[string]bool{ "billable": rf.Billable, "not-billable": rf.NotBillable, @@ -52,6 +67,7 @@ func (rf ReportFlags) Check() error { // NewReportFlags helps creating a util.ReportFlags for report commands func NewReportFlags() ReportFlags { return ReportFlags{ + Limit: 0, OutputFlags: util.OutputFlags{ TimeFormat: timehlp.FullTimeFormat, }, @@ -65,8 +81,12 @@ func AddReportFlags( util.AddPrintTimeEntriesFlags(cmd, &rf.OutputFlags) util.AddPrintMultipleTimeEntriesFlags(cmd) + cmd.Flags().IntVarP(&rf.Page, "page", "P", 0, + "set which page to return") + cmd.Flags().IntVarP(&rf.Limit, "limit", "l", 0, + "Only look for this quantity of time entries") cmd.Flags().BoolVarP(&rf.FillMissingDates, "fill-missing-dates", "e", false, - "add empty lines for dates without time entries") + "Add empty lines for dates without time entries") cmd.Flags().StringVarP(&rf.Description, "description", "d", "", "will filter time entries that contains this on the description field") cmd.Flags().StringSliceVarP(&rf.Projects, "project", "p", []string{}, @@ -157,6 +177,18 @@ func ReportWithRange( wg := errgroup.Group{} logs := make([][]dto.TimeEntry, len(rf.Projects)) + pages := api.AllPages() + if rf.Limit > 0 { + pages = api.PaginationParam{ + Page: 1, + PageSize: rf.Limit, + } + + if rf.Page > 0 { + pages.Page = rf.Page + } + } + for i := range rf.Projects { i := i wg.Go(func() error { @@ -169,7 +201,7 @@ func ReportWithRange( Description: rf.Description, ProjectID: rf.Projects[i], TagIDs: rf.TagIDs, - PaginationParam: api.AllPages(), + PaginationParam: pages, }) return err @@ -195,6 +227,10 @@ func ReportWithRange( ) }) + if rf.Limit > 0 && len(log) > rf.Limit { + log = log[len(log)-rf.Limit:] + } + if rf.FillMissingDates && len(log) > 0 { l := log log = make([]dto.TimeEntry, 0, len(l)) diff --git a/pkg/cmd/time-entry/report/util/report_flag_test.go b/pkg/cmd/time-entry/report/util/report_flag_test.go index 7f59ecae..2af4b5c5 100644 --- a/pkg/cmd/time-entry/report/util/report_flag_test.go +++ b/pkg/cmd/time-entry/report/util/report_flag_test.go @@ -7,42 +7,91 @@ import ( "github.com/stretchr/testify/assert" ) -func TestReportBillableFlagsChecks(t *testing.T) { - rf := util.NewReportFlags() - rf.Billable = true - rf.NotBillable = true - - err := rf.Check() - if assert.Error(t, err) { - assert.Regexp(t, - "can't be used together.*billable.*not-billable", err.Error()) +func TestReportFlags_Check(t *testing.T) { + tts := map[string]struct { + rf util.ReportFlags + err string + }{ + "just billable": { + rf: util.ReportFlags{ + Billable: true, + NotBillable: false, + }, + }, + "just not-billable": { + rf: util.ReportFlags{ + Billable: false, + NotBillable: true, + }, + }, + "only one billable": { + rf: util.ReportFlags{ + Billable: true, + NotBillable: true, + }, + err: "can't be used together.*billable.*not-billable", + }, + "just client": { + rf: util.ReportFlags{ + Client: "me", + }, + }, + "just projects": { + rf: util.ReportFlags{ + Projects: []string{"mine"}, + }, + }, + "client and project": { + rf: util.ReportFlags{ + Client: "me", + Projects: []string{"mine"}, + }, + }, + "fill missing dates": { + rf: util.ReportFlags{ + FillMissingDates: true, + }, + }, + "limit": { + rf: util.ReportFlags{ + Limit: 10, + }, + }, + "only limit or fill missing": { + rf: util.ReportFlags{ + Limit: 10, + FillMissingDates: true, + }, + err: "can't be used together.*fill-missing-dates.*limit", + }, + "limit and page": { + rf: util.ReportFlags{ + Limit: 10, + Page: 10, + }, + }, + "page needs limit": { + rf: util.ReportFlags{ + Page: 10, + }, + err: "page can't be used without limit", + }, } - rf.Billable = false - rf.NotBillable = true - - assert.NoError(t, rf.Check()) - - rf.Billable = true - rf.NotBillable = false - - assert.NoError(t, rf.Check()) -} + for name, tt := range tts { + t.Run(name, func(t *testing.T) { + err := tt.rf.Check() -func TestReportProjectFlagsChecks(t *testing.T) { - rf := util.NewReportFlags() - rf.Client = "me" - rf.Projects = []string{} + if tt.err == "" { + assert.NoError(t, err) + return + } - assert.NoError(t, rf.Check()) + if !assert.Error(t, err) { + return + } - rf.Client = "" - rf.Projects = []string{"mine"} - - assert.NoError(t, rf.Check()) - - rf.Client = "me" - rf.Projects = []string{"mine"} - - assert.NoError(t, rf.Check()) + assert.Regexp(t, tt.err, err.Error()) + }) + } } diff --git a/pkg/cmd/time-entry/report/util/reportwithrange_test.go b/pkg/cmd/time-entry/report/util/reportwithrange_test.go index 0bbc74eb..5b202613 100644 --- a/pkg/cmd/time-entry/report/util/reportwithrange_test.go +++ b/pkg/cmd/time-entry/report/util/reportwithrange_test.go @@ -668,6 +668,231 @@ func TestReportWithRange(t *testing.T) { 2006-01-04 22:00:00 `), }, + { + name: "limit number of time entries", + flags: func(t *testing.T) util.ReportFlags { + rf := util.NewReportFlags() + rf.Limit = 2 + rf.Quiet = true + return rf + }, + factory: func(t *testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + f.On("GetUserID").Return("u", nil) + f.On("GetWorkspaceID").Return("w", nil) + + f.EXPECT().Config().Return( + &mocks.SimpleConfig{AllowNameForID: true}) + + c := mocks.NewMockClient(t) + f.On("Client").Return(c, nil) + + c.EXPECT().LogRange(api.LogRangeParam{ + Workspace: "w", + UserID: "u", + FirstDate: first, + LastDate: last, + PaginationParam: api.PaginationParam{ + Page: 1, + PageSize: 2, + }, + }).Return([]dto.TimeEntry{ + {ID: "te-1", + TimeInterval: dto.TimeInterval{ + Start: first, + }, + }, + {ID: "te-3", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(2)), + }, + }, + }, nil) + + return f + }, + expected: heredoc.Doc(` + te-1 + te-3 + `), + }, + { + name: "limit number of time entries with client filter", + flags: func(t *testing.T) util.ReportFlags { + rf := util.NewReportFlags() + rf.Limit = 2 + rf.Client = "me" + rf.Quiet = true + return rf + }, + factory: func(t *testing.T) cmdutil.Factory { + + f := mocks.NewMockFactory(t) + f.On("GetUserID").Return("u", nil) + f.On("GetWorkspaceID").Return("w", nil) + + f.EXPECT().Config().Return( + &mocks.SimpleConfig{AllowNameForID: true}) + + c := mocks.NewMockClient(t) + f.On("Client").Return(c, nil) + + c.EXPECT().GetClients(api.GetClientsParam{ + Workspace: "w", + PaginationParam: api.AllPages(), + }). + Return([]dto.Client{ + {ID: "c1", Name: "me"}, + {ID: "c2", Name: "you"}, + }, nil) + + c.EXPECT().GetProjects(api.GetProjectsParam{ + Workspace: "w", + Clients: []string{"c1"}, + PaginationParam: api.AllPages(), + }).Return([]dto.Project{ + {ID: "p1", Name: "p1", ClientID: "c1", ClientName: "me"}, + {ID: "p3", Name: "p3", ClientID: "c1", ClientName: "me"}, + }, nil) + + p := api.PaginationParam{Page: 1, PageSize: 2} + c.EXPECT().LogRange(api.LogRangeParam{ + Workspace: "w", + UserID: "u", + ProjectID: "p1", + FirstDate: first, + LastDate: last, + PaginationParam: p, + }).Return([]dto.TimeEntry{ + {ID: "te-1", + TimeInterval: dto.TimeInterval{ + Start: first, + }, + }, + {ID: "te-3", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(2)), + }, + }, + }, nil) + + c.EXPECT().LogRange(api.LogRangeParam{ + Workspace: "w", + UserID: "u", + ProjectID: "p3", + FirstDate: first, + LastDate: last, + PaginationParam: p, + }).Return([]dto.TimeEntry{ + {ID: "te-2", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(1)), + }, + }, + }, nil) + + return f + }, + expected: heredoc.Doc(` + te-2 + te-3 + `), + }, + { + name: "only a limited page", + flags: func(t *testing.T) util.ReportFlags { + rf := util.NewReportFlags() + rf.Limit = 4 + rf.Page = 12 + rf.Client = "me" + rf.Quiet = true + return rf + }, + factory: func(t *testing.T) cmdutil.Factory { + + f := mocks.NewMockFactory(t) + f.On("GetUserID").Return("u", nil) + f.On("GetWorkspaceID").Return("w", nil) + + f.EXPECT().Config().Return( + &mocks.SimpleConfig{AllowNameForID: true}) + + c := mocks.NewMockClient(t) + f.On("Client").Return(c, nil) + + c.EXPECT().GetClients(api.GetClientsParam{ + Workspace: "w", + PaginationParam: api.AllPages(), + }). + Return([]dto.Client{ + {ID: "c1", Name: "me"}, + {ID: "c2", Name: "you"}, + }, nil) + + c.EXPECT().GetProjects(api.GetProjectsParam{ + Workspace: "w", + Clients: []string{"c1"}, + PaginationParam: api.AllPages(), + }).Return([]dto.Project{ + {ID: "p1", Name: "p1", ClientID: "c1", ClientName: "me"}, + {ID: "p3", Name: "p3", ClientID: "c1", ClientName: "me"}, + }, nil) + + p := api.PaginationParam{Page: 12, PageSize: 4} + c.EXPECT().LogRange(api.LogRangeParam{ + Workspace: "w", + UserID: "u", + ProjectID: "p1", + FirstDate: first, + LastDate: last, + PaginationParam: p, + }).Return([]dto.TimeEntry{ + {ID: "te-1", + TimeInterval: dto.TimeInterval{ + Start: first, + }, + }, + {ID: "te-3", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(2)), + }, + }, + }, nil) + + c.EXPECT().LogRange(api.LogRangeParam{ + Workspace: "w", + UserID: "u", + ProjectID: "p3", + FirstDate: first, + LastDate: last, + PaginationParam: p, + }).Return([]dto.TimeEntry{ + {ID: "te-2", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(1)), + }, + }, + {ID: "te-4", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(3)), + }, + }, + {ID: "te-5", + TimeInterval: dto.TimeInterval{ + Start: first.Add(time.Duration(4)), + }, + }, + }, nil) + + return f + }, + expected: heredoc.Doc(` + te-2 + te-3 + te-4 + te-5 + `), + }, } for _, tt := range tts {