Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add timesheet functionality #46

Merged
merged 10 commits into from
Nov 27, 2023
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/control/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
211 changes: 211 additions & 0 deletions internal/control/cli/timesheet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package cli

import (
"fmt"
"io/ioutil"
"log"
"os"
"regexp"
"strings"
"time"

"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"
"github.com/ja-he/dayplan/internal/util"
)

// TimesheetCommand is the command `timesheet`, which produces a timesheet for
// a given category.
//
// A timesheet has entries per day, each of the form
//
// <start-time>,<break-duration>,<end-time>
//
// 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:"<yyyy-mm-dd>" required:"true"`
TilDay string `short:"t" long:"til" description:"the day til which to summarize (inclusive)" value-name:"<yyyy-mm-dd>" required:"true"`

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:"<regex>"`
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:"<regex>"`

IncludeEmpty bool `long:"include-empty"`
DateFormat string `long:"date-format" value-name:"<format>" description:"specify the date format (see <https://pkg.go.dev/time#pkg-constants>)" default:"2006-01-02"`
Enquote bool `long:"enquote" description:"add quotes around field values"`
FieldSeparator string `long:"field-separator" value-name:"<CSV separator (default ',')>" default:","`
DurationFormat string `long:"duration-format" option:"golang" option:"colon-delimited" default:"golang"`
}

// 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
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()
}

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(matcher)

if !command.IncludeEmpty && timesheetEntry.IsEmpty() {
continue
}

maybeEnquote := func(s string) string {
if command.Enquote {
return util.Enquote(s)
} else {
return s
}
}

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, stringifyTimestamp, stringifyDuration, command.FieldSeparator),
},
command.FieldSeparator,
),
)
}

return nil
}

// asCSVString returns this TimesheetEntry in CSV format.
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(stringifyTimestamp(e.Start)),
processFieldString(stringifyDuration(e.BreakDuration)),
processFieldString(stringifyTimestamp(e.End)),
},
separator,
)
}
31 changes: 31 additions & 0 deletions internal/model/day.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(matcher func(string) bool) TimesheetEntry {
result := TimesheetEntry{}
startFound := false
var lastEnd Timestamp

flattened := day.Clone()
flattened.Flatten()

for _, event := range flattened.Events {

if matcher(event.Cat.Name) {

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
Expand Down
20 changes: 20 additions & 0 deletions internal/model/timesheet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package model

import (
"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
}

// IsEmpty is a helper to identify empty timesheet entries.
func (e *TimesheetEntry) IsEmpty() bool {
return e.Start == e.End
}
6 changes: 6 additions & 0 deletions internal/util/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `"`, `\"`) + `"`
}
Loading