diff --git a/Makefile b/Makefile index e117947..4a8b996 100644 --- a/Makefile +++ b/Makefile @@ -101,6 +101,10 @@ vulncheck: ## Run govulncheck scanner run: ## Run app in tracker mode (dev env), add -drop-create to recreate db go run main.go --debug track --env dev --idle 10s --interval 5s +.PHONY: punch +punch: ## Show punch clock report for default db + go run main.go --debug punch --env default + .PHONY: report-dev report-dev: ## Show report for dev env db go run main.go --debug report --env dev diff --git a/cmd/punch.go b/cmd/punch.go index 8883c28..8c9de5b 100644 --- a/cmd/punch.go +++ b/cmd/punch.go @@ -5,8 +5,6 @@ import ( "fmt" "time" - "github.com/fatih/color" - "github.com/spf13/cobra" "github.com/tillkuhn/billy-idle/pkg/tracker" ) @@ -22,9 +20,12 @@ var punchCmd = &cobra.Command{ Long: "If no args are provided, the current status for all punched records will be shown", RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { - return punchCreate(cmd.Context(), args) + if err := punchCreate(cmd.Context(), args); err != nil { + return err + } } - return punchReport(cmd.Context(), tracker.New(&punchOpts)) + t := tracker.New(&punchOpts) + return t.PunchReport(cmd.Context()) }, } @@ -35,6 +36,7 @@ func init() { punchCmd.PersistentFlags().DurationVar(&punchOpts.RegBusy, "reg-busy", 7*time.Hour+48*time.Minute, "Regular busy period per day (w/o breaks), report only") } +// punchCreate creates a new punch record for a particular day func punchCreate(ctx context.Context, args []string) error { var err error var day time.Time @@ -51,32 +53,7 @@ func punchCreate(ctx context.Context, args []string) error { return err } t := tracker.New(&punchOpts) - if err := t.UpsertPunchRecord(ctx, dur, day); err != nil { - return err - } - // show current report at the end of each new entry - return punchReport(ctx, t) + return t.UpsertPunchRecord(ctx, dur, day) } -func punchReport(ctx context.Context, t *tracker.Tracker) error { - recs, err := t.PunchRecords(ctx) - if err != nil { - return err - } - var spentBusy time.Duration - for _, r := range recs { - spentDay := time.Duration(r.BusySecs) * time.Second - fmt.Printf("🕰️ %-22s: actual busy time %v\n", r.Day.Format("2006-01-02 (Monday)"), spentDay) - spentBusy += spentDay - } - spentBusy = spentBusy.Round(time.Minute) - pDays := len(recs) - expected := time.Duration(pDays) * punchOpts.RegBusy - overtime := spentBusy - expected - color.Set(color.FgGreen) - fmt.Printf("TotalBusy(%dd): %v AvgPerDay: %v Expected(%dd*%v): %v Overtime: %v\n", - pDays, tracker.FDur(spentBusy), tracker.FDur(spentBusy/time.Duration(pDays)), - pDays, tracker.FDur(punchOpts.RegBusy), tracker.FDur(expected), tracker.FDur(overtime)) - color.Unset() - return nil -} +// punchReport displays the current punch report diff --git a/cmd/punch_test.go b/cmd/punch_test.go index 0b1ef49..6708cd8 100644 --- a/cmd/punch_test.go +++ b/cmd/punch_test.go @@ -2,14 +2,8 @@ package cmd import ( "bytes" - "context" "slices" "testing" - "time" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/jmoiron/sqlx" - "github.com/tillkuhn/billy-idle/pkg/tracker" "github.com/stretchr/testify/assert" ) @@ -57,23 +51,6 @@ func TestSum(t *testing.T) { } } -func Test_PunchReport(t *testing.T) { - mockDB, mock, err := sqlmock.New() - assert.NoError(t, err) - sqlxDB := sqlx.NewDb(mockDB, "sqlmock") - today := tracker.TruncateDay(time.Now()) - mock.ExpectQuery("SELECT (.*)"). - WillReturnRows( - mock.NewRows([]string{"day", "busy_secs"}). - AddRow(today, 3600). - AddRow(today, 7200), - ) - mock.ExpectClose() - tr := tracker.NewWithDB(&punchOpts, sqlxDB) - err = punchReport(context.Background(), tr) - assert.NoError(t, err) -} - /* func Test_ExecuteTrackCommandMissingArg(t *testing.T) { assert.ErrorContains(t, rootCmd.Execute(), "expected a duration") diff --git a/go.mod b/go.mod index ffeb265..d3f2fe6 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,9 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index 92991a1..fea0e71 100644 --- a/go.sum +++ b/go.sum @@ -31,10 +31,14 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/pkg/tracker/punch.go b/pkg/tracker/punch.go index 9b55a96..17a1f1f 100644 --- a/pkg/tracker/punch.go +++ b/pkg/tracker/punch.go @@ -2,9 +2,14 @@ package tracker import ( "context" + "fmt" "log" + "strconv" "time" + "github.com/fatih/color" + "github.com/olekukonko/tablewriter" + "github.com/pkg/errors" ) @@ -13,6 +18,53 @@ const ( hoursPerDay = 24 ) +// PunchReport displays the current punch report table layout +func (t *Tracker) PunchReport(ctx context.Context) error { + recs, err := t.PunchRecords(ctx) + if err != nil { + return err + } + var spentBusy time.Duration + table := tablewriter.NewWriter(t.opts.Out) + bold := tablewriter.Colors{tablewriter.Bold} + table.SetHeader([]string{"🕰 Date", "CW", "Weekday", "🐝 Busy Time"}) + table.SetHeaderColor(bold, bold, bold, bold) + table.SetBorder(false) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + + for _, r := range recs { + spentDay := time.Duration(r.BusySecs) * time.Second + _, week := r.Day.ISOWeek() + table.Append([]string{ + r.Day.Format(" 2006-01-02"), + strconv.Itoa(week), + r.Day.Format("Monday"), + FDur(spentDay), + }) + spentBusy += spentDay + } + + spentBusy = spentBusy.Round(time.Minute) + pDays := len(recs) + expected := time.Duration(pDays) * t.opts.RegBusy + overtime := spentBusy - expected + + // Table Footer with totals + table.SetFooter([]string{"", "", "Total\nOvertime", + fmt.Sprintf("%s (%ddays)\n%v (>%v)", FDur(spentBusy), pDays, FDur(overtime), FDur(expected)), + }) // Add Footer + table.SetFooterColor(tablewriter.Colors{}, tablewriter.Colors{}, bold, + tablewriter.Colors{tablewriter.FgHiGreenColor}) + table.Render() + + color.Set(color.FgGreen) + // fmt.Printf("AVG/DAY: %v REGULAR (%dd*%v): %v\n", tracker.FDur(spentBusy/time.Duration(pDays)), pDays, tracker.FDur(punchOpts.RegBusy) ) + color.Unset() + + return nil +} + func (t *Tracker) UpsertPunchRecord(ctx context.Context, busyDuration time.Duration, day time.Time) error { uQuery := `UPDATE ` + tablePunch + ` SET busy_secs=$2,client=$3 diff --git a/pkg/tracker/punch_test.go b/pkg/tracker/punch_test.go index 767e5fd..56d41a5 100644 --- a/pkg/tracker/punch_test.go +++ b/pkg/tracker/punch_test.go @@ -1,6 +1,7 @@ package tracker import ( + "bytes" "context" "testing" "time" @@ -50,3 +51,23 @@ func Test_SelectPunch(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 2, len(recs)) } + +func Test_PunchReport(t *testing.T) { + tr, mock := DBMock(t) + var output bytes.Buffer + tr.opts.Out = &output + + day, err := time.Parse("2006-01-02 15:04:05", "2024-01-23 13:14:15") // is a tuesday + assert.NoError(t, err) + day = TruncateDay(day) + mock.ExpectQuery("SELECT (.*)"). + WillReturnRows( + mock.NewRows([]string{"day", "busy_secs"}). + AddRow(day, 3600). + AddRow(day, 7200), + ) + mock.ExpectClose() + err = tr.PunchReport(context.Background()) + assert.NoError(t, err) + assert.Contains(t, output.String(), "Tuesday") +} diff --git a/pkg/tracker/tracker.go b/pkg/tracker/tracker.go index 93ca932..5f5b456 100644 --- a/pkg/tracker/tracker.go +++ b/pkg/tracker/tracker.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "math/rand/v2" + "os" "sync" "time" @@ -21,6 +22,9 @@ type Tracker struct { // New returns a new Tracker configured with the given Options func New(opts *Options) *Tracker { + if opts.Out == nil { + opts.Out = os.Stdout + } db, err := initDB(opts) if err != nil { log.Fatal(err) diff --git a/pkg/tracker/types.go b/pkg/tracker/types.go index 031704b..6dd08ac 100644 --- a/pkg/tracker/types.go +++ b/pkg/tracker/types.go @@ -3,6 +3,7 @@ package tracker import ( "database/sql" "fmt" + "io" "path/filepath" "time" ) @@ -20,6 +21,7 @@ type Options struct { MinBusy time.Duration MaxBusy time.Duration RegBusy time.Duration + Out io.Writer } func (o Options) AppDir() string {