From d60a7ae9843bcc64b93aae181065625b306dbbf8 Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Thu, 5 Jan 2023 14:20:38 +0100 Subject: [PATCH 01/10] feat(timesheet): Allow getting timesheet entry per day per category --- internal/model/day.go | 31 +++++++++++++++++++++++++++++++ internal/model/timesheet.go | 26 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 internal/model/timesheet.go diff --git a/internal/model/day.go b/internal/model/day.go index 6d5066bb..dee47d6f 100644 --- a/internal/model/day.go +++ b/internal/model/day.go @@ -318,6 +318,37 @@ func (day *Day) SumUpByCategory() map[Category]int { return result } +// GetTimesheetEntry returns the TimesheetEntry for this day for a given +// category (e.g. "work"). +func (day *Day) GetTimesheetEntry(categoryName string) TimesheetEntry { + result := TimesheetEntry{} + startFound := false + var lastEnd Timestamp + + flattened := day.Clone() + flattened.Flatten() + + for _, event := range flattened.Events { + + if event.Cat.Name == categoryName { + + if !startFound { + result.Start = event.Start + startFound = true + } else { + result.BreakDuration += lastEnd.DurationUntil(event.Start) + } + lastEnd = event.End + + } + + } + + result.End = lastEnd + + return result +} + // "Flattens" the events of a given day, i.E. ensures that no overlapping // events exist. It does this by e.g. trimming one of two overlapping events or // splitting a less prioritized event if it had a higher-priority event occur diff --git a/internal/model/timesheet.go b/internal/model/timesheet.go new file mode 100644 index 00000000..fbb7090e --- /dev/null +++ b/internal/model/timesheet.go @@ -0,0 +1,26 @@ +package model + +import ( + "fmt" + "time" +) + +// A TimesheetEntry represents an entry in a common timesheet. +// +// It defines a beginning (i.e. the time you clocked in), an end (i.e. the time +// you clocked out), and the total length of breaks taken between them. +type TimesheetEntry struct { + Start Timestamp + BreakDuration time.Duration + End Timestamp +} + +// ToPrintableFormat returns this TimesheetEntry in its printable (CSV) format. +func (e *TimesheetEntry) ToPrintableFormat() string { + return fmt.Sprintf( + "%s,%s,%s", + e.Start.ToString(), + e.BreakDuration.String(), + e.End.ToString(), + ) +} From e84ef8e274eaec61bd9f8956decff78556543a1a Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Thu, 5 Jan 2023 14:25:11 +0100 Subject: [PATCH 02/10] feat(timesheet): Add command 'timesheet' --- internal/control/cli/cli.go | 1 + internal/control/cli/timesheet.go | 115 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 internal/control/cli/timesheet.go diff --git a/internal/control/cli/cli.go b/internal/control/cli/cli.go index 03b72241..32b4ca2a 100644 --- a/internal/control/cli/cli.go +++ b/internal/control/cli/cli.go @@ -5,6 +5,7 @@ type CommandLineOpts struct { TuiCommand TuiCommand `command:"tui" subcommands-optional:"true"` SummarizeCommand SummarizeCommand `command:"summarize" subcommands-optional:"true"` + TimesheetCommand TimesheetCommand `command:"timesheet" subcommands-optional:"true"` AddCommand AddCommand `command:"add" subcommands-optional:"true"` VersionCommand VersionCommand `command:"version" subcommands-optional:"true"` } diff --git a/internal/control/cli/timesheet.go b/internal/control/cli/timesheet.go new file mode 100644 index 00000000..c9f22299 --- /dev/null +++ b/internal/control/cli/timesheet.go @@ -0,0 +1,115 @@ +package cli + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "strings" + + "github.com/ja-he/dayplan/internal/config" + "github.com/ja-he/dayplan/internal/control" + "github.com/ja-he/dayplan/internal/model" + "github.com/ja-he/dayplan/internal/storage" + "github.com/ja-he/dayplan/internal/styling" +) + +// TimesheetCommand is the command `timesheet`, which produces a timesheet for +// a given category. +// +// A timesheet has entries per day, each of the form +// +// ,, +// +// e.g. +// +// 08:50,45min,16:20 +type TimesheetCommand struct { + FromDay string `short:"f" long:"from" description:"the day from which to start summarizing" value-name:"" required:"true"` + TilDay string `short:"t" long:"til" description:"the day til which to summarize (inclusive)" value-name:"" required:"true"` + + Category string `long:"category" description:"the category for which to generate the timesheet" value-name:"" required:"true"` +} + +// Execute executes the timesheet command. +func (command *TimesheetCommand) Execute(args []string) error { + var envData control.EnvData + + // set up dir per option + dayplanHome := os.Getenv("DAYPLAN_HOME") + if dayplanHome == "" { + envData.BaseDirPath = os.Getenv("HOME") + "/.config/dayplan" + } else { + envData.BaseDirPath = strings.TrimRight(dayplanHome, "/") + } + + // read config from file (for the category priorities) + yamlData, err := ioutil.ReadFile(envData.BaseDirPath + "/" + "config.yaml") + if err != nil { + panic(fmt.Sprintf("can't read config file: '%s'", err)) + } + configData, err := config.ParseConfigAugmentDefaults(config.Light, yamlData) + if err != nil { + panic(fmt.Sprintf("can't parse config data: '%s'", err)) + } + styledCategories := styling.EmptyCategoryStyling() + for _, category := range configData.Categories { + var goal model.Goal + var err error + switch { + case category.Goal.Ranged != nil: + goal, err = model.NewRangedGoalFromConfig(*category.Goal.Ranged) + case category.Goal.Workweek != nil: + goal, err = model.NewWorkweekGoalFromConfig(*category.Goal.Workweek) + } + if err != nil { + return err + } + + cat := model.Category{ + Name: category.Name, + Priority: category.Priority, + Goal: goal, + } + style := styling.StyleFromHexSingle(category.Color, false) + styledCategories.Add(cat, style) + } + + startDate, err := model.FromString(command.FromDay) + if err != nil { + log.Fatalf("from date '%s' invalid", command.FromDay) + } + currentDate := startDate + finalDate, err := model.FromString(command.TilDay) + if err != nil { + log.Fatalf("til date '%s' invalid", command.TilDay) + } + + type dateAndDay struct { + model.Date + model.Day + } + + data := make([]dateAndDay, 0) + for currentDate != finalDate.Next() { + fh := storage.NewFileHandler(envData.BaseDirPath + "/days/" + currentDate.ToString()) + categories := make([]model.Category, 0) + for _, cat := range styledCategories.GetAll() { + categories = append(categories, cat.Cat) + } + data = append(data, dateAndDay{currentDate, *fh.Read(categories)}) + + currentDate = currentDate.Next() + } + + for _, dataEntry := range data { + timesheetEntry := dataEntry.Day.GetTimesheetEntry(command.Category) + fmt.Printf( + "%s,%s\n", + dataEntry.Date.ToString(), + timesheetEntry.ToPrintableFormat(), + ) + } + + return nil +} From 2c9170bb86630fe78c156cf85ca3d3aada84defe Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Thu, 5 Jan 2023 14:42:47 +0100 Subject: [PATCH 03/10] feat(timesheet): Omit empty entries by default --- internal/control/cli/timesheet.go | 7 +++++++ internal/model/timesheet.go | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/internal/control/cli/timesheet.go b/internal/control/cli/timesheet.go index c9f22299..fa0b30b7 100644 --- a/internal/control/cli/timesheet.go +++ b/internal/control/cli/timesheet.go @@ -29,6 +29,8 @@ type TimesheetCommand struct { TilDay string `short:"t" long:"til" description:"the day til which to summarize (inclusive)" value-name:"" required:"true"` Category string `long:"category" description:"the category for which to generate the timesheet" value-name:"" required:"true"` + + IncludeEmpty bool `long:"include-empty"` } // Execute executes the timesheet command. @@ -104,6 +106,11 @@ func (command *TimesheetCommand) Execute(args []string) error { for _, dataEntry := range data { timesheetEntry := dataEntry.Day.GetTimesheetEntry(command.Category) + + if !command.IncludeEmpty && timesheetEntry.IsEmpty() { + continue + } + fmt.Printf( "%s,%s\n", dataEntry.Date.ToString(), diff --git a/internal/model/timesheet.go b/internal/model/timesheet.go index fbb7090e..c4c30acf 100644 --- a/internal/model/timesheet.go +++ b/internal/model/timesheet.go @@ -24,3 +24,8 @@ func (e *TimesheetEntry) ToPrintableFormat() string { e.End.ToString(), ) } + +// IsEmpty is a helper to identify empty timesheet entries. +func (e *TimesheetEntry) IsEmpty() bool { + return e.Start == e.End +} From 105957c9528904594bb96ba3e8a8cf513145315f Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Thu, 5 Jan 2023 14:43:00 +0100 Subject: [PATCH 04/10] feat(timesheet): Trim zero-second suffix from duration strings --- internal/model/timesheet.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/model/timesheet.go b/internal/model/timesheet.go index c4c30acf..11ef2d6f 100644 --- a/internal/model/timesheet.go +++ b/internal/model/timesheet.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "strings" "time" ) @@ -17,10 +18,14 @@ type TimesheetEntry struct { // ToPrintableFormat returns this TimesheetEntry in its printable (CSV) format. func (e *TimesheetEntry) ToPrintableFormat() string { + dur := e.BreakDuration.String() + if strings.HasSuffix(dur, "m0s") { + dur = strings.TrimSuffix(dur, "0s") + } return fmt.Sprintf( "%s,%s,%s", e.Start.ToString(), - e.BreakDuration.String(), + dur, e.End.ToString(), ) } From e2c145a25d91f06f3a7c10ca5f006b57b2c0abd1 Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Fri, 6 Jan 2023 10:09:09 +0100 Subject: [PATCH 05/10] feat(timesheet): Allow enquoting of fields --- internal/control/cli/timesheet.go | 41 +++++++++++++++++++++++++++---- internal/model/timesheet.go | 16 ------------ internal/util/util.go | 6 +++++ 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/internal/control/cli/timesheet.go b/internal/control/cli/timesheet.go index fa0b30b7..e02a195b 100644 --- a/internal/control/cli/timesheet.go +++ b/internal/control/cli/timesheet.go @@ -12,6 +12,7 @@ import ( "github.com/ja-he/dayplan/internal/model" "github.com/ja-he/dayplan/internal/storage" "github.com/ja-he/dayplan/internal/styling" + "github.com/ja-he/dayplan/internal/util" ) // TimesheetCommand is the command `timesheet`, which produces a timesheet for @@ -30,7 +31,9 @@ type TimesheetCommand struct { Category string `long:"category" description:"the category for which to generate the timesheet" value-name:"" required:"true"` - IncludeEmpty bool `long:"include-empty"` + IncludeEmpty bool `long:"include-empty"` + DateFormat string `long:"date-format" description:"the date format (see )" default:"2006-01-02"` + Enquote bool `long:"enquote" description:"add quotes around field values"` } // Execute executes the timesheet command. @@ -111,12 +114,40 @@ func (command *TimesheetCommand) Execute(args []string) error { continue } - fmt.Printf( - "%s,%s\n", - dataEntry.Date.ToString(), - timesheetEntry.ToPrintableFormat(), + maybeEnquote := func(s string) string { + if command.Enquote { + return util.Enquote(s) + } else { + return s + } + } + + fmt.Println( + strings.Join( + []string{ + maybeEnquote(dataEntry.Date.ToGotime().Format(command.DateFormat)), + asCSVString(timesheetEntry, maybeEnquote), + }, + ",", + ), ) } return nil } + +// asCSVString returns this TimesheetEntry in CSV format. +func asCSVString(e model.TimesheetEntry, processFieldString func(string) string) string { + dur := e.BreakDuration.String() + if strings.HasSuffix(dur, "m0s") { + dur = strings.TrimSuffix(dur, "0s") + } + return strings.Join( + []string{ + processFieldString(e.Start.ToString()), + processFieldString(dur), + processFieldString(e.End.ToString()), + }, + ",", + ) +} diff --git a/internal/model/timesheet.go b/internal/model/timesheet.go index 11ef2d6f..d3f3d1c4 100644 --- a/internal/model/timesheet.go +++ b/internal/model/timesheet.go @@ -1,8 +1,6 @@ package model import ( - "fmt" - "strings" "time" ) @@ -16,20 +14,6 @@ type TimesheetEntry struct { End Timestamp } -// ToPrintableFormat returns this TimesheetEntry in its printable (CSV) format. -func (e *TimesheetEntry) ToPrintableFormat() string { - dur := e.BreakDuration.String() - if strings.HasSuffix(dur, "m0s") { - dur = strings.TrimSuffix(dur, "0s") - } - return fmt.Sprintf( - "%s,%s,%s", - e.Start.ToString(), - dur, - e.End.ToString(), - ) -} - // IsEmpty is a helper to identify empty timesheet entries. func (e *TimesheetEntry) IsEmpty() bool { return e.Start == e.End diff --git a/internal/util/util.go b/internal/util/util.go index aeead4eb..7a683e0e 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -50,3 +50,9 @@ func PadCenter(s string, length int) string { suffix := strings.Repeat(" ", length-len(prefix)) return prefix + s + suffix } + +// Enquote takes a string and surrounds it with quotes, escaping any quotes +// already present in the given string. +func Enquote(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` +} From cb416c62e83b3dd5e1526aa7cb7a04a4f9c3c453 Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Fri, 6 Jan 2023 12:54:24 +0100 Subject: [PATCH 06/10] feat(timesheet): Allow specifying separator --- internal/control/cli/timesheet.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/control/cli/timesheet.go b/internal/control/cli/timesheet.go index e02a195b..3b9c676a 100644 --- a/internal/control/cli/timesheet.go +++ b/internal/control/cli/timesheet.go @@ -34,6 +34,7 @@ type TimesheetCommand struct { IncludeEmpty bool `long:"include-empty"` DateFormat string `long:"date-format" description:"the date format (see )" default:"2006-01-02"` Enquote bool `long:"enquote" description:"add quotes around field values"` + Separator string `long:"separator" value-name:"" default:","` } // Execute executes the timesheet command. @@ -126,9 +127,9 @@ func (command *TimesheetCommand) Execute(args []string) error { strings.Join( []string{ maybeEnquote(dataEntry.Date.ToGotime().Format(command.DateFormat)), - asCSVString(timesheetEntry, maybeEnquote), + asCSVString(timesheetEntry, maybeEnquote, command.Separator), }, - ",", + command.Separator, ), ) } @@ -137,7 +138,7 @@ func (command *TimesheetCommand) Execute(args []string) error { } // asCSVString returns this TimesheetEntry in CSV format. -func asCSVString(e model.TimesheetEntry, processFieldString func(string) string) string { +func asCSVString(e model.TimesheetEntry, processFieldString func(string) string, separator string) string { dur := e.BreakDuration.String() if strings.HasSuffix(dur, "m0s") { dur = strings.TrimSuffix(dur, "0s") @@ -148,6 +149,6 @@ func asCSVString(e model.TimesheetEntry, processFieldString func(string) string) processFieldString(dur), processFieldString(e.End.ToString()), }, - ",", + separator, ) } From a5bd619e6c83823ea735286b5036cead91c4170b Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Fri, 6 Jan 2023 12:59:14 +0100 Subject: [PATCH 07/10] fix(timesheet): Name value for format --- internal/control/cli/timesheet.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/control/cli/timesheet.go b/internal/control/cli/timesheet.go index 3b9c676a..7341c08d 100644 --- a/internal/control/cli/timesheet.go +++ b/internal/control/cli/timesheet.go @@ -32,7 +32,7 @@ type TimesheetCommand struct { Category string `long:"category" description:"the category for which to generate the timesheet" value-name:"" required:"true"` IncludeEmpty bool `long:"include-empty"` - DateFormat string `long:"date-format" description:"the date format (see )" default:"2006-01-02"` + DateFormat string `long:"date-format" value-name:"" description:"specify the date format (see )" default:"2006-01-02"` Enquote bool `long:"enquote" description:"add quotes around field values"` Separator string `long:"separator" value-name:"" default:","` } From 22959d51bde1295dc1fb4ae4016a55766d6b0b84 Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Fri, 6 Jan 2023 13:29:38 +0100 Subject: [PATCH 08/10] doc(timesheet): Document `timesheet` in README --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 3df48f96..7afd3a48 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,35 @@ $ dayplan summarize --from 2021-11-01 --til 2021-11-30 \ --category-filter work \ --human-readable ``` + +### Getting a Timesheet (`timesheet`) + +This is similar but distinct from summaries. +You might have to fill out a timesheet at some point, of a format like the following, but probably in a spreadsheet: + +| Date | Start | Break Duration | End | +| --- | --- | --- | --- | +| 2022-12-01 | 08:45 | 45min | 16:30 | +| 2022-12-02 | 08:52 | 1h | 16:20 | +| ... | ... | ... | ... | + +You can generate the data for this with dayplan using the (`timesheet`) subcommand: +``` +$ dayplan timesheet --from "2022-12-01" \ + --til "2022-12-31" \ + --category "my-project" \ + --include-empty +``` +This would give you roughly the output +``` +2022-12-01,08:45,45min,16:30 +2022-12-02,08:52,1h,16:20 +... +``` +which should already be sufficient for opening as / copy-pasting into a spreadsheet. + +Be sure to check out `dayplan timesheet --help` as well. + ### Adding events via CLI (`add`) Besides being able to add events in the TUI mode, events can also be added via From ed7eb4ae5c953e5bdf504eaea127aed2198c519e Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Tue, 31 Jan 2023 11:14:17 +0100 Subject: [PATCH 09/10] feat(timesheet): Allow matching by include/exclude regexes --- internal/control/cli/timesheet.go | 44 ++++++++++++++++++++++++++++--- internal/model/day.go | 4 +-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/internal/control/cli/timesheet.go b/internal/control/cli/timesheet.go index 7341c08d..48734976 100644 --- a/internal/control/cli/timesheet.go +++ b/internal/control/cli/timesheet.go @@ -5,6 +5,7 @@ import ( "io/ioutil" "log" "os" + "regexp" "strings" "github.com/ja-he/dayplan/internal/config" @@ -29,16 +30,21 @@ type TimesheetCommand struct { FromDay string `short:"f" long:"from" description:"the day from which to start summarizing" value-name:"" required:"true"` TilDay string `short:"t" long:"til" description:"the day til which to summarize (inclusive)" value-name:"" required:"true"` - Category string `long:"category" description:"the category for which to generate the timesheet" value-name:"" required:"true"` - IncludeEmpty bool `long:"include-empty"` DateFormat string `long:"date-format" value-name:"" description:"specify the date format (see )" default:"2006-01-02"` Enquote bool `long:"enquote" description:"add quotes around field values"` Separator string `long:"separator" value-name:"" default:","` + CategoryIncludeFilter string `long:"category-include-filter" short:"i" description:"the category filter include regex for which to generate the timesheet (empty value is ignored)" value-name:""` + CategoryExcludeFilter string `long:"category-exclude-filter" short:"e" description:"the category filter exclude regex for which to generate the timesheet (empty value is ignored)" value-name:""` + } // Execute executes the timesheet command. func (command *TimesheetCommand) Execute(args []string) error { + if command.CategoryIncludeFilter == "" && command.CategoryExcludeFilter == "" { + return fmt.Errorf("at least one of '--category-include-filter'/'-i' and '--category-exclude-filter'/'-e' is required") + } + var envData control.EnvData // set up dir per option @@ -108,8 +114,40 @@ func (command *TimesheetCommand) Execute(args []string) error { currentDate = currentDate.Next() } + var includeRegex, excludeRegex *regexp.Regexp + if command.CategoryIncludeFilter != "" { + includeRegex, err = regexp.Compile(command.CategoryIncludeFilter) + if err != nil { + return fmt.Errorf("category include filter regex is invalid (%s)", err.Error()) + } + } + if command.CategoryExcludeFilter != "" { + excludeRegex, err = regexp.Compile(command.CategoryExcludeFilter) + if err != nil { + return fmt.Errorf("category exclude filter regex is invalid (%s)", err.Error()) + } + } + matcher := func(catName string) bool { + if includeRegex != nil && !includeRegex.MatchString(catName) { + return false + } + if excludeRegex != nil && excludeRegex.MatchString(catName) { + return false + } + return true + } + + func() { + fmt.Fprintln(os.Stderr, "PROSPECTIVE MATCHES:") + for _, cat := range configData.Categories { + if matcher(cat.Name) { + fmt.Fprintf(os.Stderr, " '%s'\n", cat.Name) + } + } + }() + for _, dataEntry := range data { - timesheetEntry := dataEntry.Day.GetTimesheetEntry(command.Category) + timesheetEntry := dataEntry.Day.GetTimesheetEntry(matcher) if !command.IncludeEmpty && timesheetEntry.IsEmpty() { continue diff --git a/internal/model/day.go b/internal/model/day.go index dee47d6f..1b6feadf 100644 --- a/internal/model/day.go +++ b/internal/model/day.go @@ -320,7 +320,7 @@ func (day *Day) SumUpByCategory() map[Category]int { // GetTimesheetEntry returns the TimesheetEntry for this day for a given // category (e.g. "work"). -func (day *Day) GetTimesheetEntry(categoryName string) TimesheetEntry { +func (day *Day) GetTimesheetEntry(matcher func(string) bool) TimesheetEntry { result := TimesheetEntry{} startFound := false var lastEnd Timestamp @@ -330,7 +330,7 @@ func (day *Day) GetTimesheetEntry(categoryName string) TimesheetEntry { for _, event := range flattened.Events { - if event.Cat.Name == categoryName { + if matcher(event.Cat.Name) { if !startFound { result.Start = event.Start From f56069f165ef08648d239a914c2c8c1559322eca Mon Sep 17 00:00:00 2001 From: Jan Hensel Date: Thu, 2 Feb 2023 13:23:53 +0100 Subject: [PATCH 10/10] feat(timesheet): Allow specification of duration format --- internal/control/cli/timesheet.go | 47 ++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/internal/control/cli/timesheet.go b/internal/control/cli/timesheet.go index 48734976..efc41c33 100644 --- a/internal/control/cli/timesheet.go +++ b/internal/control/cli/timesheet.go @@ -7,6 +7,7 @@ import ( "os" "regexp" "strings" + "time" "github.com/ja-he/dayplan/internal/config" "github.com/ja-he/dayplan/internal/control" @@ -30,13 +31,14 @@ type TimesheetCommand struct { FromDay string `short:"f" long:"from" description:"the day from which to start summarizing" value-name:"" required:"true"` TilDay string `short:"t" long:"til" description:"the day til which to summarize (inclusive)" value-name:"" required:"true"` - IncludeEmpty bool `long:"include-empty"` - DateFormat string `long:"date-format" value-name:"" description:"specify the date format (see )" default:"2006-01-02"` - Enquote bool `long:"enquote" description:"add quotes around field values"` - Separator string `long:"separator" value-name:"" default:","` CategoryIncludeFilter string `long:"category-include-filter" short:"i" description:"the category filter include regex for which to generate the timesheet (empty value is ignored)" value-name:""` CategoryExcludeFilter string `long:"category-exclude-filter" short:"e" description:"the category filter exclude regex for which to generate the timesheet (empty value is ignored)" value-name:""` + IncludeEmpty bool `long:"include-empty"` + DateFormat string `long:"date-format" value-name:"" description:"specify the date format (see )" default:"2006-01-02"` + Enquote bool `long:"enquote" description:"add quotes around field values"` + FieldSeparator string `long:"field-separator" value-name:"" default:","` + DurationFormat string `long:"duration-format" option:"golang" option:"colon-delimited" default:"golang"` } // Execute executes the timesheet command. @@ -161,13 +163,34 @@ func (command *TimesheetCommand) Execute(args []string) error { } } + stringifyTimestamp := func(ts model.Timestamp) string { + return ts.ToString() + } + + stringifyDuration := func(dur time.Duration) string { + switch command.DurationFormat { + case "golang": + str := dur.String() + if strings.HasSuffix(str, "m0s") { + str = strings.TrimSuffix(str, "0s") + } + return str + case "colon-delimited": + durHours := dur.Truncate(time.Hour) + durMins := (dur - durHours) + return fmt.Sprintf("%d:%02d", int(durHours.Hours()), int(durMins.Minutes())) + default: + panic("unhandled case '" + command.DurationFormat + "'") + } + } + fmt.Println( strings.Join( []string{ maybeEnquote(dataEntry.Date.ToGotime().Format(command.DateFormat)), - asCSVString(timesheetEntry, maybeEnquote, command.Separator), + asCSVString(timesheetEntry, maybeEnquote, stringifyTimestamp, stringifyDuration, command.FieldSeparator), }, - command.Separator, + command.FieldSeparator, ), ) } @@ -176,16 +199,12 @@ func (command *TimesheetCommand) Execute(args []string) error { } // asCSVString returns this TimesheetEntry in CSV format. -func asCSVString(e model.TimesheetEntry, processFieldString func(string) string, separator string) string { - dur := e.BreakDuration.String() - if strings.HasSuffix(dur, "m0s") { - dur = strings.TrimSuffix(dur, "0s") - } +func asCSVString(e model.TimesheetEntry, processFieldString func(string) string, stringifyTimestamp func(model.Timestamp) string, stringifyDuration func(time.Duration) string, separator string) string { return strings.Join( []string{ - processFieldString(e.Start.ToString()), - processFieldString(dur), - processFieldString(e.End.ToString()), + processFieldString(stringifyTimestamp(e.Start)), + processFieldString(stringifyDuration(e.BreakDuration)), + processFieldString(stringifyTimestamp(e.End)), }, separator, )