diff --git a/cmd/punch.go b/cmd/punch.go index ab3cbcf..6fff8c6 100644 --- a/cmd/punch.go +++ b/cmd/punch.go @@ -1,15 +1,15 @@ package cmd import ( - "context" "fmt" - "log" "time" "github.com/spf13/cobra" "github.com/tillkuhn/billy-idle/pkg/tracker" ) +var punchOpts tracker.Options + // punchCmd represents the busy command var punchCmd = &cobra.Command{ Use: "punch", @@ -17,7 +17,7 @@ var punchCmd = &cobra.Command{ Example: "punch 10h5m 2024-11-07", Args: cobra.MatchAll(cobra.MinimumNArgs(1), cobra.MaximumNArgs(2)), Long: ``, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { var err error var day time.Time day = time.Now() @@ -33,14 +33,33 @@ var punchCmd = &cobra.Command{ return err } t := tracker.New(&trackOpts) - if err := t.UpsertPunchRecord(context.Background(), dur, day); err != nil { - log.Println(err) + if err := t.UpsertPunchRecord(cmd.Context(), dur, day); err != nil { + return err } + // show status + recs, err := t.PunchRecords(cmd.Context()) + if err != nil { + return err + } + var spentBusy time.Duration + for _, r := range recs { + spentDay := time.Duration(r.BusySecs) * time.Second + fmt.Printf("🕰️ %s: actual busy time %v\n", r.Day.Format("2006-01-02"), spentDay) + spentBusy += spentDay + } + spentBusy = spentBusy.Round(time.Minute) + pDays := len(recs) + expected := time.Duration(pDays) * punchOpts.RegBusy + overtime := spentBusy - expected + 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)) return nil }, } func init() { rootCmd.AddCommand(punchCmd) + punchCmd.PersistentFlags().DurationVar(&punchOpts.RegBusy, "reg-busy", 7*time.Hour+48*time.Minute, "Regular busy period per day (w/o breaks), report only") } diff --git a/pkg/tracker/punch.go b/pkg/tracker/punch.go index 98a208e..1c7b3b2 100644 --- a/pkg/tracker/punch.go +++ b/pkg/tracker/punch.go @@ -17,7 +17,7 @@ func (t *Tracker) UpsertPunchRecord(ctx context.Context, busyDuration time.Durat uQuery := `UPDATE ` + tablePunch + ` SET busy_secs=$2,client=$3 WHERE day=$1` - day = trucateDay(day) // https://stackoverflow.com/a/38516536/4292075 + day = truncateDay(day) // https://stackoverflow.com/a/38516536/4292075 uRes, err := t.db.ExecContext(ctx, uQuery, day, busyDuration.Seconds(), t.opts.ClientID) if err != nil { return errors.Wrap(err, "unable to update busy table") @@ -37,6 +37,19 @@ func (t *Tracker) UpsertPunchRecord(ctx context.Context, busyDuration time.Durat return nil } -func trucateDay(t time.Time) time.Time { +// PunchRecords retried existing punch records for a specific time period +func (t *Tracker) PunchRecords(ctx context.Context) ([]PunchRecord, error) { + // select sum(ROUND((JULIANDAY(busy_end) - JULIANDAY(busy_start)) * 86400)) || ' secs' AS total from track + query := `SELECT day,busy_secs FROM ` + tablePunch + ` ORDER BY DAY` // WHERE busy_start >= DATE('now', '-7 days') ORDER BY busy_start LIMIT 500` + // We could use get since we expect a single result, but this would return an error if nothing is found + // which is a likely use case + var records []PunchRecord + if err := t.db.SelectContext(ctx, &records, query /*, args*/); err != nil { + return nil, err + } + return records, nil +} + +func truncateDay(t time.Time) time.Time { return t.Truncate(hoursPerDay * time.Hour).UTC() } diff --git a/pkg/tracker/punch_test.go b/pkg/tracker/punch_test.go index e4dd4c0..1bb7497 100644 --- a/pkg/tracker/punch_test.go +++ b/pkg/tracker/punch_test.go @@ -11,7 +11,7 @@ import ( func Test_UpsertPunchUpdate(t *testing.T) { tracker, mock := DBMock(t) - day := trucateDay(time.Now()) + day := truncateDay(time.Now()) sql1 := wildcardStatement("UPDATE " + tablePunch + " SET") // mock.ExpectPrepare(sql1) mock.ExpectExec(sql1).WithArgs(day, float64(3600), "test"). @@ -22,7 +22,7 @@ func Test_UpsertPunchUpdate(t *testing.T) { func Test_UpsertPunchInsert(t *testing.T) { tracker, mock := DBMock(t) - day := trucateDay(time.Now()) + day := truncateDay(time.Now()) sql1 := wildcardStatement("UPDATE " + tablePunch + " SET") // mock.ExpectPrepare(sql1) mock.ExpectExec(sql1).WithArgs(day, float64(3600), "test"). @@ -34,3 +34,19 @@ func Test_UpsertPunchInsert(t *testing.T) { err := tracker.UpsertPunchRecord(context.Background(), time.Second*3600, day) assert.NoError(t, err) } + +func Test_SelectPunch(t *testing.T) { + tr, mock := DBMock(t) + + today := truncateDay(time.Now()) + mock.ExpectQuery("SELECT (.*)"). + WillReturnRows( + mock.NewRows([]string{"day", "busy_secs"}). + AddRow(today, 3600). + AddRow(today, 7200), + ) + mock.ExpectClose() + recs, err := tr.PunchRecords(context.Background()) + assert.NoError(t, err) + assert.Equal(t, 2, len(recs)) +} diff --git a/pkg/tracker/report.go b/pkg/tracker/report.go index 6a246f1..524a4dd 100644 --- a/pkg/tracker/report.go +++ b/pkg/tracker/report.go @@ -15,6 +15,24 @@ import ( const minPerHour = 60 const sepLineLen = 100 +// trackRecords retried existing track records for a specific time period +func (t *Tracker) trackRecords(ctx context.Context) (map[string][]TrackRecord, error) { + // select sum(ROUND((JULIANDAY(busy_end) - JULIANDAY(busy_start)) * 86400)) || ' secs' AS total from track + query := `SELECT * FROM track WHERE busy_start >= DATE('now', '-7 days') ORDER BY busy_start LIMIT 500` + // We could use get since we expect a single result, but this would return an error if nothing is found + // which is a likely use case + var records []TrackRecord + if err := t.db.SelectContext(ctx, &records, query /*, args*/); err != nil { + return nil, err + } + recMap := map[string][]TrackRecord{} + for _, r := range records { + k := r.BusyStart.Format("2006-01-02") // go ref Mon Jan 2 15:04:05 -0700 MST 2006 + recMap[k] = append(recMap[k], r) + } + return recMap, nil +} + // Report experimental report for time tracking apps func (t *Tracker) Report(ctx context.Context, w io.Writer) error { recMap, err := t.trackRecords(ctx) @@ -78,12 +96,12 @@ func (t *Tracker) Report(ctx context.Context, w io.Writer) error { // todo: raise warning if totalBusy is > 10h (or busyPlus > 10:45), since more than 10h are not allowed _, _ = fmt.Fprintf(w, "Busy: %s WithBreak(%vm): %s Skipped(<%v): %d >Max(%s): %v Range: %s\n", // first.BusyStart.Format("2006-01-02 Mon"), - fDur(spentBusy), + FDur(spentBusy), kitKat.Round(time.Minute).Minutes(), - fDur((spentBusy + kitKat).Round(time.Minute)), - fDur(t.opts.MinBusy), skippedTooShort, - fDur(t.opts.MaxBusy), spentBusy > t.opts.MaxBusy, - fDur(spentTotal), + FDur((spentBusy + kitKat).Round(time.Minute)), + FDur(t.opts.MinBusy), skippedTooShort, + FDur(t.opts.MaxBusy), spentBusy > t.opts.MaxBusy, + FDur(spentTotal), ) sugStart, _ := time.Parse("15:04", "09:00") @@ -91,7 +109,7 @@ func (t *Tracker) Report(ctx context.Context, w io.Writer) error { // first.BusyStart.Format("Monday"), sugStart.Format("15:04"), sugStart.Add((spentBusy + kitKat).Round(time.Minute)).Format("15:04"), - fDur(t.opts.RegBusy), fDur(spentBusy-t.opts.RegBusy), + FDur(t.opts.RegBusy), FDur(spentBusy-t.opts.RegBusy), ) color.Unset() _, _ = fmt.Fprintln(w, strings.Repeat("=", sepLineLen)) @@ -100,8 +118,8 @@ func (t *Tracker) Report(ctx context.Context, w io.Writer) error { return nil } -// fDur formats a duration to a human readable string with hours (if > 0) and minutes -func fDur(d time.Duration) string { +// FDur formats a duration to a human-readable string with hours (if > 0) and minutes +func FDur(d time.Duration) string { switch { case d.Hours() > 0: return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%minPerHour) diff --git a/pkg/tracker/report_test.go b/pkg/tracker/report_test.go index 587cd99..b884cd5 100644 --- a/pkg/tracker/report_test.go +++ b/pkg/tracker/report_test.go @@ -27,7 +27,7 @@ func Test_Report(t *testing.T) { } func Test_FormatDuration(t *testing.T) { - assert.Equal(t, "2h5m", fDur(2*time.Hour+5*time.Minute)) - assert.Equal(t, "0h5m", fDur(5*time.Minute)) - assert.Equal(t, "-3h7m", fDur(-3*time.Hour+7*time.Minute*-1)) + assert.Equal(t, "2h5m", FDur(2*time.Hour+5*time.Minute)) + assert.Equal(t, "0h5m", FDur(5*time.Minute)) + assert.Equal(t, "-3h7m", FDur(-3*time.Hour+7*time.Minute*-1)) } diff --git a/pkg/tracker/tracker.go b/pkg/tracker/tracker.go index aaf5815..93ca932 100644 --- a/pkg/tracker/tracker.go +++ b/pkg/tracker/tracker.go @@ -146,24 +146,6 @@ func (t *Tracker) completeTrackRecordWithTime(ctx context.Context, id int, msg s return err } -// trackRecords retried existing track records for a specific time period -func (t *Tracker) trackRecords(ctx context.Context) (map[string][]TrackRecord, error) { - // select sum(ROUND((JULIANDAY(busy_end) - JULIANDAY(busy_start)) * 86400)) || ' secs' AS total from track - query := `SELECT * FROM track WHERE busy_start >= DATE('now', '-7 days') ORDER BY busy_start LIMIT 500` - // We could use get since we expect a single result, but this would return an error if nothing is found - // which is a likely use case - var records []TrackRecord - if err := t.db.SelectContext(ctx, &records, query /*, args*/); err != nil { - return nil, err - } - recMap := map[string][]TrackRecord{} - for _, r := range records { - k := r.BusyStart.Format("2006-01-02") // go ref Mon Jan 2 15:04:05 -0700 MST 2006 - recMap[k] = append(recMap[k], r) - } - return recMap, nil -} - func randomTask() string { // r := rand.IntN(3) switch rand.IntN(4) { diff --git a/pkg/tracker/types.go b/pkg/tracker/types.go index bed3a3d..031704b 100644 --- a/pkg/tracker/types.go +++ b/pkg/tracker/types.go @@ -36,11 +36,16 @@ type TrackRecord struct { Client string `db:"client"` } +type PunchRecord struct { + Day time.Time `db:"day"` + BusySecs float64 `db:"busy_secs"` +} + // String returns a string representation of the TrackRecord func (t TrackRecord) String() string { var verb, to string if t.BusyEnd.Valid { - verb = "Spent " + fDur(t.Duration()) + verb = "Spent " + FDur(t.Duration()) to = t.BusyEnd.Time.Format("15:04:05") } else { verb = "Still busy with"