From 22d8a100ad9d306e7c5155c289bf57ad3bf16c72 Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Sun, 2 Nov 2025 19:34:44 -0300 Subject: [PATCH 01/12] feat: add support for custom fields in report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include `CustomFields` attribute in TimeEntry DTO. Modify CustomField DTO to match APIs response. Parse custom field values to CSV and Markdown reports. - CSV Report Add new column `customFields...` at end. Format is: CustomField1Name(CustomField1Id)=CustomField1Value;CustomField2Name(CustomField2Id)=CustomField2Value Modify column `tags...`. Semi-colon is now the separator between multiple tags. This is a breaking change for CSV reports. New format is: Tag1Value(Tag1Id);Tag2Value(Tag2Id) - Markdown Report Added row _Custom Fields_ with pairs `key: value` separated by commas. Example: ```md _Time and date_ **1:00:00** | 19:00 - 20:00 🗓 11/02/2025 | | | |-----------------|----------------------------------------------------------------------------| | _Description_ | Some task | | _Project_ | **My Project** | | _Tags_ | My Tag 1, My Tag 2 | | _Billable_ | No | | _Custom Fields_ | Custom Field 1: Custom field value 1, Custom field 2: Custom field value 2 | ``` --- api/dto/dto.go | 29 ++++++++++++------------ pkg/output/time-entry/csv.go | 8 +++++-- pkg/output/time-entry/default.go | 10 ++++++++ pkg/output/time-entry/markdown.gotmpl.md | 26 +++++++++++++++------ 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/api/dto/dto.go b/api/dto/dto.go index af6e14a8..0869812a 100644 --- a/api/dto/dto.go +++ b/api/dto/dto.go @@ -103,19 +103,20 @@ type Rate struct { // TimeEntry DTO type TimeEntry struct { - ID string `json:"id"` - Billable bool `json:"billable"` - Description string `json:"description"` - HourlyRate Rate `json:"hourlyRate"` - IsLocked bool `json:"isLocked"` - Project *Project `json:"project"` - ProjectID string `json:"projectId"` - Tags []Tag `json:"tags"` - Task *Task `json:"task"` - TimeInterval TimeInterval `json:"timeInterval"` - TotalBillable int64 `json:"totalBillable"` - User *User `json:"user"` - WorkspaceID string `json:"workspaceId"` + ID string `json:"id"` + Billable bool `json:"billable"` + Description string `json:"description"` + HourlyRate Rate `json:"hourlyRate"` + IsLocked bool `json:"isLocked"` + Project *Project `json:"project"` + CustomFields []CustomField `json:"customFieldValues"` + ProjectID string `json:"projectId"` + Tags []Tag `json:"tags"` + Task *Task `json:"task"` + TimeInterval TimeInterval `json:"timeInterval"` + TotalBillable int64 `json:"totalBillable"` + User *User `json:"user"` + WorkspaceID string `json:"workspaceId"` } // NewTimeInterval will create a TimeInterval from start and end times @@ -200,7 +201,7 @@ func (e Client) GetName() string { return e.Name } // CustomField DTO type CustomField struct { CustomFieldID string `json:"customFieldId"` - Status string `json:"status"` + TimeEntryId string `json:"timeEntryId"` Name string `json:"name"` Type string `json:"type"` Value string `json:"value"` diff --git a/pkg/output/time-entry/csv.go b/pkg/output/time-entry/csv.go index bd708afd..99714720 100644 --- a/pkg/output/time-entry/csv.go +++ b/pkg/output/time-entry/csv.go @@ -3,6 +3,7 @@ package timeentry import ( "encoding/csv" "io" + "strings" "time" "github.com/lucassabreu/clockify-cli/api/dto" @@ -26,6 +27,7 @@ func TimeEntriesCSVPrint(timeEntries []dto.TimeEntry, out io.Writer) error { "user.email", "user.name", "tags...", + "customFields...", }); err != nil { return err } @@ -74,8 +76,10 @@ func TimeEntriesCSVPrint(timeEntries []dto.TimeEntry, out io.Writer) error { te.User.Name, } - if err := w.Write(append( - arr, tagsToStringSlice(te.Tags)...)); err != nil { + arr = append(arr, strings.Join(tagsToStringSlice(te.Tags), ";")) + arr = append(arr, strings.Join(customFieldsToStringSlice(te.CustomFields), ";")) + + if err := w.Write(arr); err != nil { return err } } diff --git a/pkg/output/time-entry/default.go b/pkg/output/time-entry/default.go index 9e042039..031b6d37 100644 --- a/pkg/output/time-entry/default.go +++ b/pkg/output/time-entry/default.go @@ -181,3 +181,13 @@ func tagsToStringSlice(tags []dto.Tag) []string { func durationToString(d time.Duration) string { return dto.Duration{Duration: d}.HumanString() } + +func customFieldsToStringSlice(customFields []dto.CustomField) []string { + s := make([]string, len(customFields)) + + for i, cf := range customFields { + s[i] = fmt.Sprintf("%s(%s)=%s", cf.Name, cf.CustomFieldID, cf.Value) + } + + return s +} diff --git a/pkg/output/time-entry/markdown.gotmpl.md b/pkg/output/time-entry/markdown.gotmpl.md index 5c63c502..538cb678 100644 --- a/pkg/output/time-entry/markdown.gotmpl.md +++ b/pkg/output/time-entry/markdown.gotmpl.md @@ -23,7 +23,18 @@ {{- $tags = "No Tags" -}} {{- end -}} -{{- $pad := maxLength .Description $project $tags $bil -}} +{{- $customFields := "" -}} +{{- with .CustomFields -}} + {{- range $index, $element := . -}} + {{- if ne $index 0 }}{{ $customFields = concat $customFields ", " }}{{ end -}} + {{- $customFields = concat $customFields $element.Name ": " $element.Value -}} + {{- end -}} +{{- else -}} + {{- $customFields = "No Custom Fields" -}} +{{- end -}} + + +{{- $pad := maxLength .Description $project $tags $customFields $bil -}} ## _Time Entry_: {{ .ID }} @@ -35,9 +46,10 @@ Start Time: _{{ formatTimeWS .TimeInterval.Start }}_ 🗓 Today {{- .TimeInterval.Start.Format " 01/02/2006" }} {{- end }} -| | {{ pad "" $pad }} | -|---------------|-{{ repeatString "-" $pad }}-| -| _Description_ | {{ pad .Description $pad }} | -| _Project_ | {{ pad $project $pad }} | -| _Tags_ | {{ pad $tags $pad }} | -| _Billable_ | {{ pad $bil $pad }} | +| | {{ pad "" $pad }} | +|-----------------|-{{ repeatString "-" $pad }}-| +| _Description_ | {{ pad .Description $pad }} | +| _Project_ | {{ pad $project $pad }} | +| _Tags_ | {{ pad $tags $pad }} | +| _Billable_ | {{ pad $bil $pad }} | +| _Custom Fields_ | {{ pad $customFields $pad }} | From a30ccc50b9bae0efbe175a9d3b31d38d794205d5 Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Wed, 12 Nov 2025 18:27:39 -0300 Subject: [PATCH 02/12] feat: add custom fields column at end of default report Example column: ``` +---------------------------------------------------------------+ | CUSTOM FIELDS | +---------------------------------------------------------------+ | Custom Field 1(5e4117fe8c625f38930d57b7)=Custom field value 1 | | Custom field 2(5e4117fe8c625f38930d57b8)=Custom field value 2 | | Custom field 3(5e4117fe8c625f38930d57b9)=Custom field value 3 | +---------------------------------------------------------------+ ``` Signed-off-by: Caleb Trepowski --- pkg/output/time-entry/default.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/output/time-entry/default.go b/pkg/output/time-entry/default.go index 031b6d37..fa04d810 100644 --- a/pkg/output/time-entry/default.go +++ b/pkg/output/time-entry/default.go @@ -96,6 +96,8 @@ func TimeEntriesPrint( header = append(header, "Description", "Tags") + header = append(header, "Custom Fields") + tw.SetHeader(header) tw.SetRowLine(true) if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil { @@ -152,6 +154,11 @@ func TimeEntriesPrint( strings.Join(tagsToStringSlice(t.Tags), "\n"), ) + line = append( + line, + strings.Join(customFieldsToStringSlice(t.CustomFields), "\n"), + ) + tw.Rich(line, colors) } From 3ab6c020bf74f65b3996dc3eb2ccc807a284781b Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Wed, 12 Nov 2025 18:28:01 -0300 Subject: [PATCH 03/12] feat: new config for custom fields Add config for custom field with value `show-custom-fields`. Add custom fields column in default report conditionally based on this new config. Signed-off-by: Caleb Trepowski --- pkg/cmd/config/init/init.go | 3 +++ pkg/cmd/config/list/list.go | 1 + pkg/cmd/config/set/set.go | 1 + pkg/cmd/time-entry/util/report.go | 4 ++++ pkg/cmdutil/config.go | 1 + pkg/output/time-entry/default.go | 22 +++++++++++++++++----- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/config/init/init.go b/pkg/cmd/config/init/init.go index 15250988..e0367e6e 100644 --- a/pkg/cmd/config/init/init.go +++ b/pkg/cmd/config/init/init.go @@ -85,6 +85,9 @@ func NewCmdInit(f cmdutil.Factory) *cobra.Command { updateFlag(i, config, cmdutil.CONF_SHOW_TASKS, `Should show task on time entries as a separated column?`, ), + updateFlag(i, config, cmdutil.CONF_SHOW_CUSTOM_FIELDS, + `Should show custom fields on time entries as a separated column?`, + ), updateFlag(i, config, cmdutil.CONF_SHOW_CLIENT, `Should show client on time entries as a separated column?`, ), diff --git a/pkg/cmd/config/list/list.go b/pkg/cmd/config/list/list.go index 89684b78..a3673bbb 100644 --- a/pkg/cmd/config/list/list.go +++ b/pkg/cmd/config/list/list.go @@ -25,6 +25,7 @@ func NewCmdList(f cmdutil.Factory) *cobra.Command { interactive: true no-closing: false show-task: false + show-custom-fields: false show-total-duration: true token: Yamdas569 user: diff --git a/pkg/cmd/config/set/set.go b/pkg/cmd/config/set/set.go index d4fb6d68..75418ce8 100644 --- a/pkg/cmd/config/set/set.go +++ b/pkg/cmd/config/set/set.go @@ -30,6 +30,7 @@ func NewCmdSet( $ %[1]s token "Yamdas569" $ %[1]s workweek-days monday,tuesday,wednesday,thursday,friday $ %[1]s show-task true + $ %[1]s show-custom-fields true $ %[1]s user.id 4564d5a6s4d54a5s4dasd5 `, "clockify-cli config set"), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cmd/time-entry/util/report.go b/pkg/cmd/time-entry/util/report.go index 0b0038ec..aa074772 100644 --- a/pkg/cmd/time-entry/util/report.go +++ b/pkg/cmd/time-entry/util/report.go @@ -149,6 +149,10 @@ func PrintTimeEntries( opts = opts.WithShowTasks() } + if config.GetBool(cmdutil.CONF_SHOW_CUSTOM_FIELDS) { + opts = opts.WithShowCustomFieds() + } + if config.GetBool(cmdutil.CONF_SHOW_CLIENT) { opts = opts.WithShowClients() } diff --git a/pkg/cmdutil/config.go b/pkg/cmdutil/config.go index c2f06830..0f1d50a2 100644 --- a/pkg/cmdutil/config.go +++ b/pkg/cmdutil/config.go @@ -22,6 +22,7 @@ const ( CONF_TOKEN = "token" CONF_ALLOW_INCOMPLETE = "allow-incomplete" CONF_SHOW_TASKS = "show-task" + CONF_SHOW_CUSTOM_FIELDS = "show-custom-fields" CONF_SHOW_CLIENT = "show-client" CONF_DESCR_AUTOCOMP = "description-autocomplete" CONF_DESCR_AUTOCOMP_DAYS = "description-autocomplete-days" diff --git a/pkg/output/time-entry/default.go b/pkg/output/time-entry/default.go index fa04d810..528ddfdd 100644 --- a/pkg/output/time-entry/default.go +++ b/pkg/output/time-entry/default.go @@ -36,6 +36,7 @@ const ( // entries type TimeEntryOutputOptions struct { ShowTasks bool + ShowCustomFields bool ShowClients bool ShowTotalDuration bool TimeFormat string @@ -46,6 +47,7 @@ func NewTimeEntryOutputOptions() TimeEntryOutputOptions { return TimeEntryOutputOptions{ TimeFormat: TimeFormatSimple, ShowTasks: false, + ShowCustomFields: false, ShowClients: false, ShowTotalDuration: false, } @@ -65,6 +67,12 @@ func (teo TimeEntryOutputOptions) WithShowTasks() TimeEntryOutputOptions { return teo } +// WithShowCustomFields shows a new column with the custom fields of the time entry +func (teo TimeEntryOutputOptions) WithShowCustomFieds() TimeEntryOutputOptions { + teo.ShowCustomFields = true + return teo +} + // WithShowCliens shows a new column with the client of the time entry func (teo TimeEntryOutputOptions) WithShowClients() TimeEntryOutputOptions { teo.ShowClients = true @@ -96,7 +104,9 @@ func TimeEntriesPrint( header = append(header, "Description", "Tags") - header = append(header, "Custom Fields") + if options.ShowCustomFields { + header = append(header, "Custom Fields") + } tw.SetHeader(header) tw.SetRowLine(true) @@ -154,10 +164,12 @@ func TimeEntriesPrint( strings.Join(tagsToStringSlice(t.Tags), "\n"), ) - line = append( - line, - strings.Join(customFieldsToStringSlice(t.CustomFields), "\n"), - ) + if options.ShowCustomFields { + line = append( + line, + strings.Join(customFieldsToStringSlice(t.CustomFields), "\n"), + ) + } tw.Rich(line, colors) } From da15289fd86621c5c98fb552642b16f0dc74224f Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Wed, 12 Nov 2025 18:29:01 -0300 Subject: [PATCH 04/12] docs: include custom field column in csv report example Additionally remove extra line in markdown template. Signed-off-by: Caleb Trepowski --- pkg/cmd/time-entry/report/report.go | 6 +++--- pkg/output/time-entry/markdown.gotmpl.md | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/time-entry/report/report.go b/pkg/cmd/time-entry/report/report.go index 7f8ba15a..e6be0608 100644 --- a/pkg/cmd/time-entry/report/report.go +++ b/pkg/cmd/time-entry/report/report.go @@ -124,9 +124,9 @@ func NewCmdReport(f cmdutil.Factory) *cobra.Command { # csv format output $ %[1]s --csv - id,description,project.id,project.name,task.id,task.name,start,end,duration,user.id,user.email,user.name,tags... - 62b87a9785815e619d7ce02e,Example for today,621948458cb9606d934ebb1c,Clockify Cli,62b87a7e984dba2c0669724d,Report Command,2022-06-26 12:25:56,2022-06-26 12:26:47,0:00:51,5c6bf21db079873a55facc08,joe@due.com,John Due,Development (62ae28b72518aa18da2acb49) - 62b87abb85815e619d7ce034,Example for today (second one),621948458cb9606d934ebb1c,Clockify Cli,62b87a7e984dba2c0669724d,Report Command,2022-06-26 12:26:47,2022-06-26 13:00:00,0:33:13,5c6bf21db079873a55facc08,joe@due.com,John Due,Development (62ae28b72518aa18da2acb49) + id,description,project.id,project.name,task.id,task.name,start,end,duration,user.id,user.email,user.name,tags...,customFields... + 62b87a9785815e619d7ce02e,Example for today,621948458cb9606d934ebb1c,Clockify Cli,62b87a7e984dba2c0669724d,Report Command,2022-06-26 12:25:56,2022-06-26 12:26:47,0:00:51,5c6bf21db079873a55facc08,joe@due.com,John Due,Development (62ae28b72518aa18da2acb49),Example custom field(5e1147fe8c526f38930d57b8)=value + 62b87abb85815e619d7ce034,Example for today (second one),621948458cb9606d934ebb1c,Clockify Cli,62b87a7e984dba2c0669724d,Report Command,2022-06-26 12:26:47,2022-06-26 13:00:00,0:33:13,5c6bf21db079873a55facc08,joe@due.com,John Due,Development (62ae28b72518aa18da2acb49), Example custom field(5e1147fe8c526f38930d57b8)=value `, "clockify-cli report", "`", timehlp.FullTimeFormat, timehlp.OnlyTimeFormat, diff --git a/pkg/output/time-entry/markdown.gotmpl.md b/pkg/output/time-entry/markdown.gotmpl.md index 538cb678..ce28d85c 100644 --- a/pkg/output/time-entry/markdown.gotmpl.md +++ b/pkg/output/time-entry/markdown.gotmpl.md @@ -33,7 +33,6 @@ {{- $customFields = "No Custom Fields" -}} {{- end -}} - {{- $pad := maxLength .Description $project $tags $customFields $bil -}} ## _Time Entry_: {{ .ID }} From c3c6c4259e2aca2e04f3b7d4df6c0d2408f4b634 Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Wed, 12 Nov 2025 18:58:35 -0300 Subject: [PATCH 05/12] fix: typo in WithShowCustomFields function name Signed-off-by: Caleb Trepowski --- pkg/cmd/time-entry/util/report.go | 2 +- pkg/output/time-entry/default.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/time-entry/util/report.go b/pkg/cmd/time-entry/util/report.go index aa074772..cfc25f39 100644 --- a/pkg/cmd/time-entry/util/report.go +++ b/pkg/cmd/time-entry/util/report.go @@ -150,7 +150,7 @@ func PrintTimeEntries( } if config.GetBool(cmdutil.CONF_SHOW_CUSTOM_FIELDS) { - opts = opts.WithShowCustomFieds() + opts = opts.WithShowCustomFields() } if config.GetBool(cmdutil.CONF_SHOW_CLIENT) { diff --git a/pkg/output/time-entry/default.go b/pkg/output/time-entry/default.go index 528ddfdd..57874ce5 100644 --- a/pkg/output/time-entry/default.go +++ b/pkg/output/time-entry/default.go @@ -68,7 +68,7 @@ func (teo TimeEntryOutputOptions) WithShowTasks() TimeEntryOutputOptions { } // WithShowCustomFields shows a new column with the custom fields of the time entry -func (teo TimeEntryOutputOptions) WithShowCustomFieds() TimeEntryOutputOptions { +func (teo TimeEntryOutputOptions) WithShowCustomFields() TimeEntryOutputOptions { teo.ShowCustomFields = true return teo } From 0624c2f1165b7b7a06e310f5dee09f98d577de3a Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Thu, 20 Nov 2025 20:29:31 -0300 Subject: [PATCH 06/12] feat: update tests for init command Include custom fields option. Signed-off-by: Caleb Trepowski --- pkg/cmd/config/init/init_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/config/init/init_test.go b/pkg/cmd/config/init/init_test.go index ce80e964..cfc96a74 100644 --- a/pkg/cmd/config/init/init_test.go +++ b/pkg/cmd/config/init/init_test.go @@ -99,6 +99,7 @@ func TestInitCmd(t *testing.T) { setBoolFn(config, cmdutil.CONF_ALLOW_INCOMPLETE, false, false) setBoolFn(config, cmdutil.CONF_SHOW_TASKS, true, true) + setBoolFn(config, cmdutil.CONF_SHOW_CUSTOM_FIELDS, true, true) setBoolFn(config, cmdutil.CONF_SHOW_CLIENT, true, true) setBoolFn(config, cmdutil.CONF_SHOW_TOTAL_DURATION, true, true) setBoolFn(config, cmdutil.CONF_DESCR_AUTOCOMP, false, true) @@ -191,6 +192,10 @@ func TestInitCmd(t *testing.T) { c.SendLine("") c.ExpectString("Yes") + c.ExpectString("show custom fields") + c.SendLine("") + c.ExpectString("Yes") + c.ExpectString("show client on time entries") c.SendLine("") c.ExpectString("Yes") From de117ff5eddea5b05ce6d63a2cf705f5b5293465 Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Thu, 20 Nov 2025 22:16:14 -0300 Subject: [PATCH 07/12] feat: update markdown template for custom fields option and update tests Since config is not passed to mardown time entries report, the condition to show custom fields row is based on the existance of any of them. If there are no custom fields, or there are custom fields with empty values, the row is not displayed. However the tests are updated to match the padding for the `Custom fields` row in the first column. Signed-off-by: Caleb Trepowski --- pkg/output/time-entry/markdown.gotmpl.md | 12 ++-- pkg/output/time-entry/markdown_test.go | 84 ++++++++++++------------ 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/pkg/output/time-entry/markdown.gotmpl.md b/pkg/output/time-entry/markdown.gotmpl.md index ce28d85c..a7affd98 100644 --- a/pkg/output/time-entry/markdown.gotmpl.md +++ b/pkg/output/time-entry/markdown.gotmpl.md @@ -24,13 +24,15 @@ {{- end -}} {{- $customFields := "" -}} +{{- $hasCustomFields := false -}} {{- with .CustomFields -}} {{- range $index, $element := . -}} - {{- if ne $index 0 }}{{ $customFields = concat $customFields ", " }}{{ end -}} - {{- $customFields = concat $customFields $element.Name ": " $element.Value -}} + {{- if ne $element.Value "" -}} + {{- if $hasCustomFields }}{{ $customFields = concat $customFields ", " }}{{ end -}} + {{- $customFields = concat $customFields $element.Name ": " $element.Value -}} + {{- $hasCustomFields = true -}} + {{- end -}} {{- end -}} -{{- else -}} - {{- $customFields = "No Custom Fields" -}} {{- end -}} {{- $pad := maxLength .Description $project $tags $customFields $bil -}} @@ -51,4 +53,6 @@ Start Time: _{{ formatTimeWS .TimeInterval.Start }}_ 🗓 Today | _Project_ | {{ pad $project $pad }} | | _Tags_ | {{ pad $tags $pad }} | | _Billable_ | {{ pad $bil $pad }} | +{{- if $hasCustomFields }} | _Custom Fields_ | {{ pad $customFields $pad }} | +{{- end }} diff --git a/pkg/output/time-entry/markdown_test.go b/pkg/output/time-entry/markdown_test.go index 8e145c88..4e29b1d2 100644 --- a/pkg/output/time-entry/markdown_test.go +++ b/pkg/output/time-entry/markdown_test.go @@ -37,12 +37,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **1:05:01** | Start Time: _%s_ 🗓 Today - | | | - |---------------|--------------------------| - | _Description_ | Open and without project | - | _Project_ | No Project | - | _Tags_ | No Tags | - | _Billable_ | No | + | | | + |-----------------|--------------------------| + | _Description_ | Open and without project | + | _Project_ | No Project | + | _Tags_ | No Tags | + | _Billable_ | No | `, t65Min1SecAgo.UTC().Format(timehlp.SimplerOnlyTimeFormat)), }, { @@ -63,12 +63,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|----------------------------| - | _Description_ | Closed and without project | - | _Project_ | No Project | - | _Tags_ | No Tags | - | _Billable_ | No | + | | | + |-----------------|----------------------------| + | _Description_ | Closed and without project | + | _Project_ | No Project | + | _Tags_ | No Tags | + | _Billable_ | No | `), }, { @@ -92,12 +92,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|------------------| - | _Description_ | With project | - | _Project_ | **Project Name** | - | _Tags_ | No Tags | - | _Billable_ | No | + | | | + |-----------------|------------------| + | _Description_ | With project | + | _Project_ | **Project Name** | + | _Tags_ | No Tags | + | _Billable_ | No | `), }, { @@ -122,12 +122,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|--------------------------------| - | _Description_ | With project | - | _Project_ | **Project Name** - Client Name | - | _Tags_ | No Tags | - | _Billable_ | Yes | + | | | + |-----------------|--------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name** - Client Name | + | _Tags_ | No Tags | + | _Billable_ | Yes | `), }, { @@ -155,12 +155,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|-----------------------------| - | _Description_ | With project | - | _Project_ | **Project Name**: Task Name | - | _Tags_ | No Tags | - | _Billable_ | Yes | + | | | + |-----------------|-----------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | No Tags | + | _Billable_ | Yes | `), }, { @@ -191,12 +191,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|-----------------------------| - | _Description_ | With project | - | _Project_ | **Project Name**: Task Name | - | _Tags_ | Stand-up Meeting | - | _Billable_ | Yes | + | | | + |-----------------|-----------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | Stand-up Meeting | + | _Billable_ | Yes | `), }, { @@ -228,12 +228,12 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 - | | | - |---------------|----------------------------------| - | _Description_ | With project | - | _Project_ | **Project Name**: Task Name | - | _Tags_ | A Tag with long name, Normal tag | - | _Billable_ | Yes | + | | | + |-----------------|----------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | A Tag with long name, Normal tag | + | _Billable_ | Yes | `), }, } From c0268298889c4319394f1dcbee77ddfc851d59d6 Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Thu, 20 Nov 2025 23:04:12 -0300 Subject: [PATCH 08/12] feat: add tests for custom fields with empty and non-empty value Signed-off-by: Caleb Trepowski --- pkg/output/time-entry/markdown_test.go | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/pkg/output/time-entry/markdown_test.go b/pkg/output/time-entry/markdown_test.go index 4e29b1d2..b8534d59 100644 --- a/pkg/output/time-entry/markdown_test.go +++ b/pkg/output/time-entry/markdown_test.go @@ -228,6 +228,95 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 + | | | + |-----------------|----------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | A Tag with long name, Normal tag | + | _Billable_ | Yes | + `), + }, + { + name: "Closed with project, client, task, tags and a custom field with non empty value", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: true, + Description: "With project", + Project: &dto.Project{ + Name: "Project Name", + ClientName: "Client Name", + }, + Task: &dto.Task{ + Name: "Task Name", + }, + CustomFields: []dto.CustomField{{ + CustomFieldID: "abcdef123456", + Name: "A custom field name", + TimeEntryId: "te1", + Type: "DROPDOWN_SINGLE", + Value: "A custom field value", + }}, + Tags: []dto.Tag{ + {Name: "A Tag with long name"}, + {Name: "Normal tag"}, + }, + TimeInterval: dto.NewTimeInterval( + start, + &end, + ), + }}, + output: heredoc.Doc(` + ## _Time Entry_: te1 + + _Time and date_ + **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 + + | | | + |-----------------|-------------------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | A Tag with long name, Normal tag | + | _Billable_ | Yes | + | _Custom Fields_ | A custom field name: A custom field value | + `), + }, + { + name: "Closed with project, client, task, tags and a custom field with an empty value", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: true, + Description: "With project", + Project: &dto.Project{ + Name: "Project Name", + ClientName: "Client Name", + }, + Task: &dto.Task{ + Name: "Task Name", + }, + CustomFields: []dto.CustomField{{ + CustomFieldID: "abcdef123456", + Name: "A custom field name", + TimeEntryId: "te1", + Type: "DROPDOWN_SINGLE", + Value: "", + }}, + Tags: []dto.Tag{ + {Name: "A Tag with long name"}, + {Name: "Normal tag"}, + }, + TimeInterval: dto.NewTimeInterval( + start, + &end, + ), + }}, + output: heredoc.Doc(` + ## _Time Entry_: te1 + + _Time and date_ + **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 + | | | |-----------------|----------------------------------| | _Description_ | With project | From 65cf4a347c6418a63bbebf75b4ba523310a9034c Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Thu, 20 Nov 2025 23:40:31 -0300 Subject: [PATCH 09/12] feat: add support for multiple values in custom field Custom field value can be either a string or an array of strings. The DTO is modified to support both types, and a function is added as getter of the value. Single string is returned as-is, multiple strings are joined together using `|` as separator. Signed-off-by: Caleb Trepowski --- api/dto/dto.go | 36 ++++++++++++++++++++---- pkg/output/time-entry/default.go | 2 +- pkg/output/time-entry/markdown.gotmpl.md | 7 +++-- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/api/dto/dto.go b/api/dto/dto.go index 0869812a..92220f73 100644 --- a/api/dto/dto.go +++ b/api/dto/dto.go @@ -2,6 +2,7 @@ package dto import ( "fmt" + "strings" "time" ) @@ -200,11 +201,36 @@ func (e Client) GetName() string { return e.Name } // CustomField DTO type CustomField struct { - CustomFieldID string `json:"customFieldId"` - TimeEntryId string `json:"timeEntryId"` - Name string `json:"name"` - Type string `json:"type"` - Value string `json:"value"` + CustomFieldID string `json:"customFieldId"` + TimeEntryId string `json:"timeEntryId"` + Name string `json:"name"` + Type string `json:"type"` + Value interface{} `json:"value"` +} + +// ValueAsString converter for CustomFieldDTO +/* + Custom field `Value` can be either a string or an array of strings. + This function is used to get the value always as string, using the `|` symbol + as separator between each individual string. +*/ +func (cf CustomField) ValueAsString() string { + switch v := cf.Value.(type) { + case string: + return v + case []interface{}: + parts := make([]string, len(v)) + for i, item := range v { + parts[i] = fmt.Sprint(item) + } + return strings.Join(parts, "|") + case []string: + return strings.Join(v, "|") + case nil: + return "" + default: + return fmt.Sprint(v) + } } // Project DTO diff --git a/pkg/output/time-entry/default.go b/pkg/output/time-entry/default.go index 57874ce5..925ff8e7 100644 --- a/pkg/output/time-entry/default.go +++ b/pkg/output/time-entry/default.go @@ -205,7 +205,7 @@ func customFieldsToStringSlice(customFields []dto.CustomField) []string { s := make([]string, len(customFields)) for i, cf := range customFields { - s[i] = fmt.Sprintf("%s(%s)=%s", cf.Name, cf.CustomFieldID, cf.Value) + s[i] = fmt.Sprintf("%s(%s)=%s", cf.Name, cf.CustomFieldID, cf.ValueAsString()) } return s diff --git a/pkg/output/time-entry/markdown.gotmpl.md b/pkg/output/time-entry/markdown.gotmpl.md index a7affd98..f1196342 100644 --- a/pkg/output/time-entry/markdown.gotmpl.md +++ b/pkg/output/time-entry/markdown.gotmpl.md @@ -27,9 +27,10 @@ {{- $hasCustomFields := false -}} {{- with .CustomFields -}} {{- range $index, $element := . -}} - {{- if ne $element.Value "" -}} - {{- if $hasCustomFields }}{{ $customFields = concat $customFields ", " }}{{ end -}} - {{- $customFields = concat $customFields $element.Name ": " $element.Value -}} + {{- $value := $element.ValueAsString -}} + {{- if ne $value "" -}} + {{- if ne $index 0 }}{{ $customFields = concat $customFields ", " }}{{ end -}} + {{- $customFields = concat $customFields $element.Name ": " $value -}} {{- $hasCustomFields = true -}} {{- end -}} {{- end -}} From 2bf1ee6bf7d4b2282b3d90043efab5b381382d96 Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Thu, 20 Nov 2025 23:43:31 -0300 Subject: [PATCH 10/12] feat: add test for custom field with multiple values Signed-off-by: Caleb Trepowski --- pkg/output/time-entry/markdown_test.go | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/pkg/output/time-entry/markdown_test.go b/pkg/output/time-entry/markdown_test.go index b8534d59..83bd1564 100644 --- a/pkg/output/time-entry/markdown_test.go +++ b/pkg/output/time-entry/markdown_test.go @@ -325,6 +325,59 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { | _Billable_ | Yes | `), }, + { + name: "Closed with project, client, task, tags and a custom field non empty value and a custom field with multiple values", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: true, + Description: "With project", + Project: &dto.Project{ + Name: "Project Name", + ClientName: "Client Name", + }, + Task: &dto.Task{ + Name: "Task Name", + }, + CustomFields: []dto.CustomField{ + { + CustomFieldID: "abcdef123456", + Name: "A custom field name", + TimeEntryId: "te1", + Type: "DROPDOWN_SINGLE", + Value: "A custom field value", + }, + { + CustomFieldID: "abcdef123457", + Name: "Another custom field name", + TimeEntryId: "te1", + Type: "DROPDOWN_MULTIPLE", + Value: []string{"Value 1", "Value 2"}, + }}, + Tags: []dto.Tag{ + {Name: "A Tag with long name"}, + {Name: "Normal tag"}, + }, + TimeInterval: dto.NewTimeInterval( + start, + &end, + ), + }}, + output: heredoc.Doc(` + ## _Time Entry_: te1 + + _Time and date_ + **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 + + | | | + |-----------------|---------------------------------------------------------------------------------------| + | _Description_ | With project | + | _Project_ | **Project Name**: Task Name | + | _Tags_ | A Tag with long name, Normal tag | + | _Billable_ | Yes | + | _Custom Fields_ | A custom field name: A custom field value, Another custom field name: Value 1|Value 2 | + `), + }, } for _, tt := range tts { From 5b52dfb71cec522df5f6456868f0ef1c1bf07209 Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Thu, 20 Nov 2025 23:44:50 -0300 Subject: [PATCH 11/12] fix: remove trailing spaces in markdown template and related tests Signed-off-by: Caleb Trepowski --- pkg/output/time-entry/markdown.gotmpl.md | 2 +- pkg/output/time-entry/markdown_test.go | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/output/time-entry/markdown.gotmpl.md b/pkg/output/time-entry/markdown.gotmpl.md index f1196342..98d74b21 100644 --- a/pkg/output/time-entry/markdown.gotmpl.md +++ b/pkg/output/time-entry/markdown.gotmpl.md @@ -40,7 +40,7 @@ ## _Time Entry_: {{ .ID }} -_Time and date_ +_Time and date_ **{{ dsf .TimeInterval.Duration }}** | {{ if eq .TimeInterval.End nil -}} Start Time: _{{ formatTimeWS .TimeInterval.Start }}_ 🗓 Today {{- else -}} diff --git a/pkg/output/time-entry/markdown_test.go b/pkg/output/time-entry/markdown_test.go index 83bd1564..ec33e3da 100644 --- a/pkg/output/time-entry/markdown_test.go +++ b/pkg/output/time-entry/markdown_test.go @@ -34,7 +34,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Docf(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **1:05:01** | Start Time: _%s_ 🗓 Today | | | @@ -60,7 +60,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -89,7 +89,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -119,7 +119,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -152,7 +152,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -188,7 +188,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -225,7 +225,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | From 8965bce1a129b8ff1d8d95a9d5b3ece5815f0f66 Mon Sep 17 00:00:00 2001 From: Caleb Trepowski Date: Fri, 21 Nov 2025 00:34:42 -0300 Subject: [PATCH 12/12] fix: restore trailing whitespaces in markdown report template These are necessary to markdown interpreters to know that the line break is intentional. Signed-off-by: Caleb Trepowski --- pkg/output/time-entry/markdown.gotmpl.md | 2 +- pkg/output/time-entry/markdown_test.go | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/output/time-entry/markdown.gotmpl.md b/pkg/output/time-entry/markdown.gotmpl.md index 98d74b21..f1196342 100644 --- a/pkg/output/time-entry/markdown.gotmpl.md +++ b/pkg/output/time-entry/markdown.gotmpl.md @@ -40,7 +40,7 @@ ## _Time Entry_: {{ .ID }} -_Time and date_ +_Time and date_ **{{ dsf .TimeInterval.Duration }}** | {{ if eq .TimeInterval.End nil -}} Start Time: _{{ formatTimeWS .TimeInterval.Start }}_ 🗓 Today {{- else -}} diff --git a/pkg/output/time-entry/markdown_test.go b/pkg/output/time-entry/markdown_test.go index ec33e3da..4b7df859 100644 --- a/pkg/output/time-entry/markdown_test.go +++ b/pkg/output/time-entry/markdown_test.go @@ -34,7 +34,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Docf(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **1:05:01** | Start Time: _%s_ 🗓 Today | | | @@ -60,7 +60,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -89,7 +89,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -119,7 +119,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -152,7 +152,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -188,7 +188,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -225,7 +225,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -269,7 +269,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -314,7 +314,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | | @@ -366,7 +366,7 @@ func TestTimeEntriesMarkdownPrint(t *testing.T) { output: heredoc.Doc(` ## _Time Entry_: te1 - _Time and date_ + _Time and date_ **0:02:01** | 10:00 - 10:02 🗓 06/15/2024 | | |