diff --git a/CHANGELOG.md b/CHANGELOG.md index c69c7ebd..47e7e6c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- markdown output now tries to resemble the time entry calendar dialog + ## [v0.53.1] - 2024-06-14 ### Fixed diff --git a/api/dto/request.go b/api/dto/request.go index 051b8247..3ecaaf50 100644 --- a/api/dto/request.go +++ b/api/dto/request.go @@ -2,6 +2,7 @@ package dto import ( "encoding/json" + "fmt" "net/url" "strconv" "strings" @@ -41,8 +42,18 @@ func (d *Duration) UnmarshalJSON(b []byte) error { return errors.Wrap(err, "unmarshal duration") } + dc, err := StringToDuration(s) + if err != nil { + return err + } + + *d = Duration{dc} + return err +} + +func StringToDuration(s string) (time.Duration, error) { if len(s) < 4 { - return errors.Errorf("duration %s is invalid", b) + return 0, errors.Errorf("duration %s is invalid", s) } var u, dc time.Duration @@ -64,18 +75,35 @@ func (d *Duration) UnmarshalJSON(b []byte) error { v, err := strconv.Atoi(s[j:i]) if err != nil { - return errors.Wrap(err, "unmarshal duration") + return 0, errors.Wrap(err, "cast cast "+s[j:i]+" to int") } dc = dc + time.Duration(v)*u j = i + 1 } - *d = Duration{Duration: dc} - return nil + return dc, nil } func (d Duration) String() string { - return "PT" + strings.ToUpper(d.Duration.String()) + s := d.Duration.String() + i := strings.LastIndex(s, ".") + if i > -1 { + s = s[0:i] + "s" + } + + return "PT" + strings.ToUpper(s) +} + +func (dd Duration) HumanString() string { + d := dd.Duration + p := "" + if d < 0 { + p = "-" + d = d * -1 + } + + return p + fmt.Sprintf("%d:%02d:%02d", + int64(d.Hours()), int64(d.Minutes())%60, int64(d.Seconds())%60) } type pagination struct { diff --git a/pkg/output/time-entry/default.go b/pkg/output/time-entry/default.go index bd3dcea3..136b1aaa 100644 --- a/pkg/output/time-entry/default.go +++ b/pkg/output/time-entry/default.go @@ -179,12 +179,5 @@ func tagsToStringSlice(tags []dto.Tag) []string { } func durationToString(d time.Duration) string { - p := "" - if d < 0 { - p = "-" - d = d * -1 - } - - return p + fmt.Sprintf("%d:%02d:%02d", - int64(d.Hours()), int64(d.Minutes())%60, int64(d.Seconds())%60) + return dto.Duration{Duration: d}.HumanString() } diff --git a/pkg/output/time-entry/markdown.go b/pkg/output/time-entry/markdown.go index 51b94588..11f106a4 100644 --- a/pkg/output/time-entry/markdown.go +++ b/pkg/output/time-entry/markdown.go @@ -7,7 +7,7 @@ import ( "github.com/lucassabreu/clockify-cli/api/dto" ) -//go:embed template.gotmpl.md +//go:embed markdown.gotmpl.md var mdTemplate string // TimeEntriesMarkdownPrint will print time entries in "markdown blocks" diff --git a/pkg/output/time-entry/markdown.gotmpl.md b/pkg/output/time-entry/markdown.gotmpl.md new file mode 100644 index 00000000..5c63c502 --- /dev/null +++ b/pkg/output/time-entry/markdown.gotmpl.md @@ -0,0 +1,43 @@ +{{- $project := "" -}} +{{- if eq .Project nil }} + {{- $project = "No Project" -}} +{{- else -}} + {{- $project = concat "**" .Project.Name "**" -}} + {{- if ne .Task nil -}} + {{- $project = concat $project ": " .Task.Name -}} + {{- else if ne .Project.ClientName "" -}} + {{- $project = concat $project " - " .Project.ClientName -}} + {{- end -}} +{{- end -}} + +{{- $bil := "No" -}} +{{- if .Billable -}}{{ $bil = "Yes" }}{{- end -}} + +{{- $tags := "" -}} +{{- with .Tags -}} + {{- range $index, $element := . -}} + {{- if ne $index 0 }}{{ $tags = concat $tags ", " }}{{ end -}} + {{- $tags = concat $tags $element.Name -}} + {{- end -}} +{{- else -}} + {{- $tags = "No Tags" -}} +{{- end -}} + +{{- $pad := maxLength .Description $project $tags $bil -}} + +## _Time Entry_: {{ .ID }} + +_Time and date_ +**{{ dsf .TimeInterval.Duration }}** | {{ if eq .TimeInterval.End nil -}} +Start Time: _{{ formatTimeWS .TimeInterval.Start }}_ 🗓 Today +{{- else -}} +{{ formatTimeWS .TimeInterval.Start }} - {{ formatTimeWS .TimeInterval.End }} 🗓 +{{- .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 }} | diff --git a/pkg/output/time-entry/markdown_test.go b/pkg/output/time-entry/markdown_test.go new file mode 100644 index 00000000..8e145c88 --- /dev/null +++ b/pkg/output/time-entry/markdown_test.go @@ -0,0 +1,253 @@ +package timeentry_test + +import ( + "strings" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/lucassabreu/clockify-cli/api/dto" + timeentry "github.com/lucassabreu/clockify-cli/pkg/output/time-entry" + "github.com/lucassabreu/clockify-cli/pkg/timehlp" + "github.com/stretchr/testify/assert" +) + +func TestTimeEntriesMarkdownPrint(t *testing.T) { + t65Min1SecAgo, _ := timehlp.ConvertToTime("-65m1s") + start, _ := time.Parse(timehlp.FullTimeFormat, "2024-06-15 10:00:01") + end := start.Add(2*time.Minute + 1*time.Second) + + tts := []struct { + name string + tes []dto.TimeEntry + output string + }{ + { + name: "open without tags or project", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: false, + Description: "Open and without project", + TimeInterval: dto.NewTimeInterval(t65Min1SecAgo, nil), + }}, + output: heredoc.Docf(` + ## _Time Entry_: te1 + + _Time and date_ + **1:05:01** | Start Time: _%s_ 🗓 Today + + | | | + |---------------|--------------------------| + | _Description_ | Open and without project | + | _Project_ | No Project | + | _Tags_ | No Tags | + | _Billable_ | No | + `, t65Min1SecAgo.UTC().Format(timehlp.SimplerOnlyTimeFormat)), + }, + { + name: "closed without tags or project", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: false, + Description: "Closed and without project", + 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_ | Closed and without project | + | _Project_ | No Project | + | _Tags_ | No Tags | + | _Billable_ | No | + `), + }, + { + name: "Closed with project", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: false, + Description: "With project", + Project: &dto.Project{ + Name: "Project Name", + }, + 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** | + | _Tags_ | No Tags | + | _Billable_ | No | + `), + }, + { + name: "Closed with project with client", + tes: []dto.TimeEntry{{ + WorkspaceID: "w1", + ID: "te1", + Billable: true, + Description: "With project", + Project: &dto.Project{ + Name: "Project Name", + ClientName: "Client Name", + }, + 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** - Client Name | + | _Tags_ | No Tags | + | _Billable_ | Yes | + `), + }, + { + name: "Closed with project, client and task", + 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", + }, + 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_ | No Tags | + | _Billable_ | Yes | + `), + }, + { + name: "Closed with project, client, task and a tag", + 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", + }, + Tags: []dto.Tag{ + {Name: "Stand-up Meeting"}, + }, + 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_ | Stand-up Meeting | + | _Billable_ | Yes | + `), + }, + { + name: "Closed with project, client, task and tags", + 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", + }, + 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 | + `), + }, + } + + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + buffer := &strings.Builder{} + err := timeentry.TimeEntriesMarkdownPrint(tt.tes, buffer) + + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, tt.output+"\n", buffer.String()) + }) + } +} diff --git a/pkg/output/time-entry/template.gotmpl.md b/pkg/output/time-entry/template.gotmpl.md deleted file mode 100644 index 6889875f..00000000 --- a/pkg/output/time-entry/template.gotmpl.md +++ /dev/null @@ -1,28 +0,0 @@ -ID: `{{ .ID }}` -Billable: `{{ if .Billable }}yes{{ else }}no{{ end }}` -Locked: `{{ if .IsLocked }}yes{{ else }}no{{ end }}` -Project: {{ if eq .ProjectID "" -}} - No Project -{{- else -}} - {{ .Project.Name }} (`{{ .Project.ID }}`) -{{- end }} -{{ with .Task -}} -Task: {{ .Name }} (`{{ .ID }}`) -{{ end -}} -Interval: `{{ formatDateTime .TimeInterval.Start }}` until `{{ with .TimeInterval.End -}} - {{ formatDateTime . }} -{{- else -}} - now -{{- end }}` -Description: -> {{ .Description }} -{{- with .Tags }} - -Tags: -{{- range . }} - * {{ .Name }} (`{{ .ID }}`) -{{- end -}} -{{- end -}} -{{- if not .Last }} ---- -{{ end -}} diff --git a/pkg/output/util/template.go b/pkg/output/util/template.go index 02bb3e58..be552e97 100644 --- a/pkg/output/util/template.go +++ b/pkg/output/util/template.go @@ -23,6 +23,7 @@ var funcMap = template.FuncMap{ "formatDateTime": formatTime(timehlp.FullTimeFormat), "fdt": formatTime(timehlp.FullTimeFormat), "formatTime": formatTime(timehlp.OnlyTimeFormat), + "formatTimeWS": formatTime(timehlp.SimplerOnlyTimeFormat), "ft": formatTime(timehlp.OnlyTimeFormat), "now": func(t *time.Time) time.Time { if t == nil { @@ -57,6 +58,34 @@ var funcMap = template.FuncMap{ "until": func(s time.Time, e ...time.Time) dto.Duration { return diff(firstOrNow(e), s) }, + "repeatString": strings.Repeat, + "maxLength": func(s ...string) int { + length := 0 + for i := range s { + l := len(s[i]) + if l > length { + length = l + } + } + + return length + }, + "concat": func(ss ...string) string { + b := &strings.Builder{} + for _, s := range ss { + b.WriteString(s) + } + + return b.String() + }, + "dsf": func(ds string) string { + d, err := dto.StringToDuration(ds) + if err != nil { + panic(err) + } + + return dto.Duration{Duration: d}.HumanString() + }, } func firstOrNow(ts []time.Time) time.Time {