From 4b01b4530199108eaab55e77a37cd5ea4626f566 Mon Sep 17 00:00:00 2001 From: Dhruv Thakur Date: Tue, 11 Jun 2024 18:13:16 +0200 Subject: [PATCH] feat: log based on user provided date range --- .github/workflows/build.yml | 3 + .github/workflows/release.yml | 2 + README.md | 34 ++++++++++ cmd/root.go | 22 ++++++- go.mod | 4 ++ go.sum | 6 ++ internal/ui/date_helpers.go | 54 ++++++++++++++++ internal/ui/date_helpers_test.go | 86 +++++++++++++++++++++++++ internal/ui/log.go | 106 +++++++++++++++++++++++++++---- internal/ui/model.go | 4 +- internal/ui/render_helpers.go | 4 +- 11 files changed, 310 insertions(+), 15 deletions(-) create mode 100644 internal/ui/date_helpers.go create mode 100644 internal/ui/date_helpers_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aa336ce..b473373 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,3 +20,6 @@ jobs: - name: Build run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fbd2c0..1c6f134 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,8 @@ jobs: go-version: '1.22.0' - name: Build run: go build -v ./... + - name: Test + run: go test -v ./... - name: Install Cosign uses: sigstore/cosign-installer@v3 with: diff --git a/README.md b/README.md index 61735c6..838d289 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,15 @@ Besides a TUI, `hours` also offers reports and logs based on the time tracking you do. These can be viewed using the subcommands `report` and `log` respectively. +### Reports + +Reports show time spent on tasks in the last `n` days. These can also be +aggregated (using `-a`) to consolidate all task entries and show the cumulative +time spent on each task per day. + +This subcommand accepts a `-p` flag, which can be anything in the range [1-7] +(both inclusive) to see reports for the last "n" days (including today). + ``` hours report -h @@ -56,6 +65,31 @@ Flags: -p, --plain whether to output report without any formatting ``` +```bash +hours report +# or +hours report -n=7 +``` + +### Logs + +As the name suggests, logs are just that: list of task entries you've saved +using `hours`. This subcommand accepts an argument, which can be one of the following: + +- `all`: all recent log entries (in reverse chronological order) +- `today`: for log entries from today +- `yest`: for log entries from yesterday +- ``: for log entries from that day +- ``: for log entries from in that range + +```bash +hours log today +# or +hours log 2024/06/08 +# or +hours log 2024/06/08...2024/06/12 +``` + 📋 TUI Reference Manual --- diff --git a/cmd/root.go b/cmd/root.go index 6c11310..63222b6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,9 +64,27 @@ var reportCmd = &cobra.Command{ var logCmd = &cobra.Command{ Use: "log", - Short: "Output task log entries (in reverse chronological order)", + Short: "Output task log entries", + Long: `Output task log entries + +Accepts an argument, which can be one of the following: + + all: all recent log entries (in reverse chronological order) + today: for log entries from today + yest: for log entries from yesterday + date: for log entries from that day (eg. "2024/06/08") + range: for log entries from that range (eg. "2024/06/08...2024/06/12") + `, + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - ui.RenderTaskLog(db, os.Stdout, reportOrLogPlain) + if len(args) == 0 { + ui.RenderTaskLog(db, os.Stdout, reportOrLogPlain, "all") + } else { + if args[0] == "" { + die("Time period shouldn't be empty\n") + } + ui.RenderTaskLog(db, os.Stdout, reportOrLogPlain, args[0]) + } }, } diff --git a/go.mod b/go.mod index fe2f3b0..f8321c8 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.9.0 modernc.org/sqlite v1.30.0 ) @@ -19,6 +20,7 @@ require ( github.com/charmbracelet/x/input v0.1.0 // indirect github.com/charmbracelet/x/term v0.1.1 // indirect github.com/charmbracelet/x/windows v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -32,6 +34,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect @@ -40,6 +43,7 @@ require ( golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.3.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.50.9 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index 835a8e4..0f5bf0c 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKU github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -68,6 +70,8 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= @@ -84,7 +88,9 @@ golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= diff --git a/internal/ui/date_helpers.go b/internal/ui/date_helpers.go new file mode 100644 index 0000000..0117d72 --- /dev/null +++ b/internal/ui/date_helpers.go @@ -0,0 +1,54 @@ +package ui + +import ( + "errors" + "fmt" + "strings" + "time" +) + +const ( + timePeriodHoursUpperBound = 168 +) + +var ( + timePeriodNotValidErr = errors.New("time period is not valid") + timePeriodTooLargeErr = fmt.Errorf("time period is too large; maximum allowed time period is %d hours", timePeriodHoursUpperBound) +) + +type timePeriod struct { + start time.Time + end time.Time +} + +func parseDateDuration(period string) (timePeriod, error) { + var tp timePeriod + + elements := strings.Split(period, "...") + if len(elements) != 2 { + return tp, timePeriodNotValidErr + } + + start, err := time.ParseInLocation(string(dateFormat), elements[0], time.Local) + if err != nil { + return tp, timePeriodNotValidErr + } + + end, err := time.ParseInLocation(string(dateFormat), elements[1], time.Local) + if err != nil { + return tp, timePeriodNotValidErr + } + + if end.Sub(start) <= 0 { + return tp, timePeriodNotValidErr + } + + if end.Sub(start).Hours() >= timePeriodHoursUpperBound { + return tp, timePeriodTooLargeErr + } + + tp.start = start + tp.end = end + + return tp, nil +} diff --git a/internal/ui/date_helpers_test.go b/internal/ui/date_helpers_test.go new file mode 100644 index 0000000..a8fae0f --- /dev/null +++ b/internal/ui/date_helpers_test.go @@ -0,0 +1,86 @@ +package ui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseDateDuration(t *testing.T) { + testCases := []struct { + name string + input string + expectedStartStr string + expectedEndStr string + err error + }{ + // success + { + name: "a range of 1 day", + input: "2024/06/10...2024/06/11", + expectedStartStr: "2024/06/10 00:00", + expectedEndStr: "2024/06/11 00:00", + }, + { + name: "a range of 3 days", + input: "2024/06/29...2024/07/01", + expectedStartStr: "2024/06/29 00:00", + expectedEndStr: "2024/07/01 00:00", + }, + // failures + { + name: "empty string", + input: "", + err: timePeriodNotValidErr, + }, + { + name: "only one date", + input: "2024/06/10", + err: timePeriodNotValidErr, + }, + { + name: "badly formatted start date", + input: "2024/0610...2024/06/10", + err: timePeriodNotValidErr, + }, + { + name: "badly formatted end date", + input: "2024/06/10...2024/0610", + err: timePeriodNotValidErr, + }, + { + name: "a range of 0 days", + input: "2024/06/10...2024/06/10", + err: timePeriodNotValidErr, + }, + { + name: "end date before start date", + input: "2024/06/10...2024/06/08", + err: timePeriodNotValidErr, + }, + { + name: "a range of 8 days", + input: "2024/06/29...2024/07/06", + err: timePeriodTooLargeErr, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, err := parseDateDuration(tt.input) + + startStr := got.start.Format(timeFormat) + endStr := got.end.Format(timeFormat) + + if tt.err == nil { + assert.Equal(t, tt.expectedStartStr, startStr) + assert.Equal(t, tt.expectedEndStr, endStr) + assert.Nil(t, err) + } else { + assert.Equal(t, tt.err, err) + } + + }) + } + +} diff --git a/internal/ui/log.go b/internal/ui/log.go index 3277c6b..45f758a 100644 --- a/internal/ui/log.go +++ b/internal/ui/log.go @@ -5,37 +5,121 @@ import ( "fmt" "io" "os" + "strings" + "time" "github.com/charmbracelet/lipgloss" - "github.com/dustin/go-humanize" "github.com/olekukonko/tablewriter" ) -func RenderTaskLog(db *sql.DB, writer io.Writer, plain bool) { - taskLogEntries, err := fetchTLEntriesFromDB(db, true, 20) - if err != nil { - fmt.Fprintf(writer, "Something went wrong generating the log:\n%s", err) +const ( + logLimit = 100 +) + +func RenderTaskLog(db *sql.DB, writer io.Writer, plain bool, period string) { + if period == "" { + fmt.Fprint(writer, "Something went wrong, time period shouldn't be empty\n") os.Exit(1) } - if len(taskLogEntries) == 0 { + switch period { + case "all": + taskLogEntries, err := fetchTLEntriesFromDB(db, true, 20) + if err != nil { + fmt.Fprintf(writer, "Something went wrong generating the log: %s\n", err) + os.Exit(1) + } + renderTaskLog(writer, plain, taskLogEntries) + + case "today": + today := time.Now() + + start := time.Date(today.Year(), + today.Month(), + today.Day(), + 0, + 0, + 0, + 0, + today.Location(), + ) + taskLogEntries, err := fetchTLEntriesBetweenTSFromDB(db, start, start.AddDate(0, 0, 1), logLimit) + if err != nil { + fmt.Fprintf(writer, "Something went wrong generating the log: %s\n", err) + os.Exit(1) + } + renderTaskLog(writer, plain, taskLogEntries) + + case "yest": + yest := time.Now().AddDate(0, 0, -1) + + start := time.Date(yest.Year(), + yest.Month(), + yest.Day(), + 0, + 0, + 0, + 0, + yest.Location(), + ) + taskLogEntries, err := fetchTLEntriesBetweenTSFromDB(db, start, start.AddDate(0, 0, 1), logLimit) + if err != nil { + fmt.Fprintf(writer, "Something went wrong generating the log: %s\n", err) + os.Exit(1) + } + renderTaskLog(writer, plain, taskLogEntries) + + default: + var start, end time.Time + var err error + + if strings.Contains(period, "...") { + var ts timePeriod + ts, err = parseDateDuration(period) + if err != nil { + fmt.Fprintf(writer, "%s\n", err) + os.Exit(1) + } + start = ts.start + end = ts.end.AddDate(0, 0, 1) + } else { + start, err = time.ParseInLocation(string(dateFormat), period, time.Local) + if err != nil { + fmt.Fprintf(writer, "Couldn't parse date: %s\n", err) + os.Exit(1) + } + end = start.AddDate(0, 0, 1) + } + + taskLogEntries, err := fetchTLEntriesBetweenTSFromDB(db, start, end, logLimit) + if err != nil { + fmt.Fprintf(writer, "Something went wrong generating the log: %s\n", err) + os.Exit(1) + } + renderTaskLog(writer, plain, taskLogEntries) + } +} + +func renderTaskLog(writer io.Writer, plain bool, entries []taskLogEntry) { + + if len(entries) == 0 { return } - data := make([][]string, len(taskLogEntries)) + data := make([][]string, len(entries)) var timeSpentStr string rs := getReportStyles(plain) styleCache := make(map[string]lipgloss.Style) - for i, entry := range taskLogEntries { + for i, entry := range entries { timeSpentStr = humanizeDuration(entry.secsSpent) if plain { data[i] = []string{ Trim(entry.taskSummary, 50), Trim(entry.comment, 80), - fmt.Sprintf("%s (%s)", entry.beginTS.Format(timeFormat), humanize.Time(entry.beginTS)), + fmt.Sprintf("%s ... %s", entry.beginTS.Format(timeFormat), entry.beginTS.Format(timeFormat)), timeSpentStr, } } else { @@ -47,14 +131,14 @@ func RenderTaskLog(db *sql.DB, writer io.Writer, plain bool) { data[i] = []string{ reportStyle.Render(Trim(entry.taskSummary, 50)), reportStyle.Render(Trim(entry.comment, 80)), - reportStyle.Render(fmt.Sprintf("%s (%s)", entry.beginTS.Format(timeFormat), humanize.Time(entry.beginTS))), + reportStyle.Render(fmt.Sprintf("%s ... %s", entry.beginTS.Format(timeFormat), entry.endTS.Format(timeFormat))), reportStyle.Render(timeSpentStr), } } } table := tablewriter.NewWriter(writer) - headerValues := []string{"Task", "Comment", "Begin", "TimeSpent"} + headerValues := []string{"Task", "Comment", "Duration", "TimeSpent"} headers := make([]string, len(headerValues)) for i, h := range headerValues { headers[i] = rs.headerStyle.Render(h) diff --git a/internal/ui/model.go b/internal/ui/model.go index 2aa3440..988ff8b 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -65,7 +65,9 @@ const ( ) const ( - timeFormat = "2006/01/02 15:04" + timeFormat = "2006/01/02 15:04" + friendlyTimeFormat = "Mon, 15:04" + dateFormat = "2006/01/02" ) type model struct { diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 1a33fc7..83c7cd3 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -31,7 +31,9 @@ func (t *task) updateDesc() { func (tl *taskLogEntry) updateDesc() { timeSpentStr := humanizeDuration(tl.secsSpent) - timeStr := fmt.Sprintf("%s (spent %s)", RightPadTrim(humanize.Time(tl.beginTS), 30, true), timeSpentStr) + timeStr := fmt.Sprintf("ended on %s (spent %s)", + RightPadTrim(tl.endTS.Format(friendlyTimeFormat), 40, true), + timeSpentStr) tl.desc = fmt.Sprintf("%s %s", RightPadTrim("["+tl.taskSummary+"]", 60, true), timeStr) }