Skip to content

Commit

Permalink
feat: add upload status indicator (#10)
Browse files Browse the repository at this point in the history
* refactor(cmd-utils): move printer to cmd utils
* build: remove go clean call as it is insane
* feat(progress-indicator): add initial progress indicator
* chore(changelog): update changelog
  • Loading branch information
gabor-boros authored Oct 14, 2021
1 parent 21ce60a commit d27c124
Show file tree
Hide file tree
Showing 12 changed files with 156 additions and 59 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file.
- Add basic clockify client implementation ([cb04282](https://github.com/gabor-boros/minutes/commit/cb04282b206bc1a926ab6e37b4cd67450e2c4766))
- Add initial CLI implementation ([98a6759](https://github.com/gabor-boros/minutes/commit/98a6759ec7557d5bdc5e313f00086cc468ee4197))
- Add initial timewarrior integration ([748a304](https://github.com/gabor-boros/minutes/commit/748a30424cc8ad61eb0be44c9e5bf3e32a905ace))
- Add initial progress indicator ([cfd860c](https://github.com/gabor-boros/minutes/commit/cfd860c13e4aaba5862ed007b17d34ebcd5db221))

**Miscellaneous Tasks**

Expand All @@ -39,6 +40,7 @@ All notable changes to this project will be documented in this file.
- Add issue templates ([99fba16](https://github.com/gabor-boros/minutes/commit/99fba16dc5a695d42d9dfee21fc7dad64ce98afe))
- Add virtualenv to gitignore ([466aa6d](https://github.com/gabor-boros/minutes/commit/466aa6d7d3cba1aba26185873c606d16c3e59483))
- Refactor and add badges ([72f091f](https://github.com/gabor-boros/minutes/commit/72f091f8fcfb18584e51e9064d7691de2abc5217))
- Add pull request template ([21ce60a](https://github.com/gabor-boros/minutes/commit/21ce60a68125fe3bf22e6505becda6249b9cdcdf))

**Refactor**

Expand All @@ -55,6 +57,7 @@ All notable changes to this project will be documented in this file.
- Add entry duration splitting as a method ([4fbb077](https://github.com/gabor-boros/minutes/commit/4fbb077aa7bc1bb8f214e981544b92ec13425164))
- Use outsourced entry duration splitting ([7be81c2](https://github.com/gabor-boros/minutes/commit/7be81c2431468679a753547a2a225c3b9560c8fb))
- Wrap errors into client.ErrFetchEntries ([90f3f2b](https://github.com/gabor-boros/minutes/commit/90f3f2bfe008e8c1d6e82ef0d8255dd50ba4ed0f))
- Move printer to cmd utils ([f6dc3b8](https://github.com/gabor-boros/minutes/commit/f6dc3b8359bac27be6dd943b71328c33054566e2))

**Testing**

Expand All @@ -69,6 +72,8 @@ All notable changes to this project will be documented in this file.
- Add post build hook to call upx ([6391c0f](https://github.com/gabor-boros/minutes/commit/6391c0f16b0dab7d4693eb3d4f3215d6fecfffa2))
- User .Version in snapshot name ([d3299d3](https://github.com/gabor-boros/minutes/commit/d3299d3416836439a4400be3819ab152b19c322f))
- Add coverage reporting ([5911595](https://github.com/gabor-boros/minutes/commit/5911595e2c71b348eac7972bc52864e0140e7b76))
- Add several Makefile improvements ([291bc75](https://github.com/gabor-boros/minutes/commit/291bc754cdb2feb644a4d0733c0675ceddcaee05))
- Remove go clean call as it is insane ([4753e1d](https://github.com/gabor-boros/minutes/commit/4753e1de5a70cb2e14507e7f802c4448d5f7db74))

**Ci**

Expand Down
1 change: 0 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,3 @@ docs: ## Serve the documentation site locally

clean: ## Clean up project root
rm -rf bin/ "$(COVERAGE_OUT)" "$(COVERAGE_HTML)"
go clean -r -i -cache -testcache -modcache
19 changes: 1 addition & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,7 @@

## About The Project

```plaintext
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Worklog entries (2021-10-04 02:00:00 +0200 CEST - 2021-10-05 02:00:00 +0200 CEST) │
├────┬─────────┬────────────────────────────────┬──────────────────────┬───────────────┬─────────────────────┬─────────────────────┬───────────┬──────────┤
│ │ TASK │ SUMMARY │ PROJECT │ CLIENT │ START │ END │ BILLED │ UNBILLED │
├────┼─────────┼────────────────────────────────┼──────────────────────┼───────────────┼─────────────────────┼─────────────────────┼───────────┼──────────┤
│ 1 │ MIN-001 │ Create an mkdocs based doc... │ Document every... │ Example Corp. │ 2021-10-04 08:00:00 │ 2021-10-04 08:20:37 │ 20m37s │ 0s │
│ 2 │ MIN-002 │ Add column text truncating │ Time syncing tool │ Example Corp. │ 2021-10-04 14:33:56 │ 2021-10-04 14:46:52 │ 12m56s │ 0s │
│ 3 │ MIN-007 │ Some very long summary tha... │ Time syncing tool │ Example Corp. │ 2021-10-04 15:45:32 │ 2021-10-04 15:53:21 │ 7m49s │ 0s │
│ 4 │ MIN-008 │ New table formatted output │ Time syncing tool │ Example Corp. │ 2021-10-04 19:11:51 │ 2021-10-04 19:56:01 │ 44m10s │ 0s │
│ 5 │ MIN-014 │ Debug time parsing issues │ Time syncing tool │ Example Corp. │ 2021-10-04 21:44:05 │ 2021-10-04 22:15:53 │ 31m48s │ 0s │
├────┼─────────┼────────────────────────────────┼──────────────────────┼───────────────┼─────────────────────┼─────────────────────┼───────────┼──────────┤
│ │ │ │ │ │ │ total time spent │ 10h18m11s │ 0s │
└────┴─────────┴────────────────────────────────┴──────────────────────┴───────────────┴─────────────────────┴─────────────────────┴───────────┴──────────┘
You have 5 complete and 0 incomplete items. Before proceeding, please double-check them.
Continue? [y/n]:
```
![minutes](./www/docs/assets/img/hero.png)

Minutes is a CLI tool for synchronizing work logs between multiple time trackers, invoicing, and bookkeeping software to make entrepreneurs' daily work easier. Every source and destination comes with their specific flags. Before using any flags, check the related documentation.

Expand Down
58 changes: 46 additions & 12 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import (

"github.com/gabor-boros/minutes/internal/pkg/client/timewarrior"

"github.com/gabor-boros/minutes/internal/cmd/printer"
"github.com/gabor-boros/minutes/internal/cmd/utils"
"github.com/gabor-boros/minutes/internal/pkg/client/clockify"
"github.com/gabor-boros/minutes/internal/pkg/client/tempo"

"github.com/jedib0t/go-pretty/v6/progress"
"github.com/jedib0t/go-pretty/v6/table"

"github.com/gabor-boros/minutes/internal/pkg/client"
Expand Down Expand Up @@ -120,8 +120,8 @@ func initCommonFlags() {
rootCmd.Flags().StringP("target-user", "", "", "set the source user ID")
rootCmd.Flags().StringP("target", "t", "", fmt.Sprintf("set the target of the sync %v", targets))

rootCmd.Flags().StringSliceP("table-sort-by", "", []string{printer.ColumnStart, printer.ColumnProject, printer.ColumnTask, printer.ColumnSummary}, fmt.Sprintf("sort table by column %v", printer.Columns))
rootCmd.Flags().StringSliceP("table-hide-column", "", []string{}, fmt.Sprintf("hide table column %v", printer.HideableColumns))
rootCmd.Flags().StringSliceP("table-sort-by", "", []string{utils.ColumnStart, utils.ColumnProject, utils.ColumnTask, utils.ColumnSummary}, fmt.Sprintf("sort table by column %v", utils.Columns))
rootCmd.Flags().StringSliceP("table-hide-column", "", []string{}, fmt.Sprintf("hide table column %v", utils.HideableColumns))

rootCmd.Flags().BoolP("tags-as-tasks", "", false, "treat tags matching the value of tags-as-tasks-regex as tasks")
rootCmd.Flags().StringP("tags-as-tasks-regex", "", "", "regex of the task pattern")
Expand Down Expand Up @@ -188,14 +188,14 @@ func validateFlags() {
column = sortBy[1:]
}

if !utils.IsSliceContains(column, printer.Columns) {
cobra.CheckErr(fmt.Sprintf("\"%s\" is not part of the sortable columns %v\n", column, printer.Columns))
if !utils.IsSliceContains(column, utils.Columns) {
cobra.CheckErr(fmt.Sprintf("\"%s\" is not part of the sortable columns %v\n", column, utils.Columns))
}
}

for _, column := range viper.GetStringSlice("table-hide-column") {
if !utils.IsSliceContains(column, printer.HideableColumns) {
cobra.CheckErr(fmt.Sprintf("\"%s\" is not part of the hideable columns %v\n", column, printer.HideableColumns))
if !utils.IsSliceContains(column, utils.HideableColumns) {
cobra.CheckErr(fmt.Sprintf("\"%s\" is not part of the hideable columns %v\n", column, utils.HideableColumns))
}
}

Expand Down Expand Up @@ -374,16 +374,16 @@ func runRootCmd(_ *cobra.Command, _ []string) {
err = viper.UnmarshalKey("table-column-truncates", &columnTruncates)
cobra.CheckErr(err)

tablePrinter := printer.NewTablePrinter(&printer.TablePrinterOpts{
BasePrinterOpts: printer.BasePrinterOpts{
tablePrinter := utils.NewTablePrinter(&utils.TablePrinterOpts{
BasePrinterOpts: utils.BasePrinterOpts{
Output: os.Stdout,
AutoIndex: true,
Title: fmt.Sprintf("Worklog entries (%s - %s)", start.Local().String(), end.Local().String()),
SortBy: viper.GetStringSlice("table-sort-by"),
HiddenColumns: viper.GetStringSlice("table-hide-column"),
},
Style: table.StyleLight,
ColumnConfig: printer.ParseColumnConfigs(
ColumnConfig: utils.ParseColumnConfigs(
"table-column-config.%s",
viper.GetStringSlice("table-hide-column"),
),
Expand All @@ -398,15 +398,49 @@ func runRootCmd(_ *cobra.Command, _ []string) {
os.Exit(0)
}

// In worst case, the maximum number of errors will match the number of entries
uploadErrChan := make(chan error, len(completeEntries))

fmt.Printf("\nUploading worklog entries:\n\n")
if !viper.GetBool("dry-run") {
err = uploader.UploadEntries(context.Background(), completeEntries, &client.UploadOpts{
progressUpdateFrequency := progress.DefaultUpdateFrequency
progressWriter := utils.NewProgressWriter(progressUpdateFrequency)

// Intentionally called as a goroutine
go progressWriter.Render()

uploader.UploadEntries(context.Background(), completeEntries, uploadErrChan, &client.UploadOpts{
RoundToClosestMinute: viper.GetBool("round-to-closest-minute"),
TreatDurationAsBilled: viper.GetBool("force-billed-duration"),
CreateMissingResources: false,
User: viper.GetString("target-user"),
ProgressWriter: progressWriter,
})
cobra.CheckErr(err)

// Wait for at least one tracker to appear and while the rendering is in progress,
// wait for the remaining updates to render.
time.Sleep(time.Second)
for progressWriter.IsRenderInProgress() {
time.Sleep(progressUpdateFrequency)
}
}

var uploadErrors []error
for i := 0; i < len(completeEntries); i++ {
if err := <-uploadErrChan; err != nil {
uploadErrors = append(uploadErrors, err)
}
}

if errCount := len(uploadErrors); errCount != 0 {
fmt.Printf("\nFailed to upload %d worklog entries!\n\n", errCount)
for _, err := range uploadErrors {
fmt.Println(err)
}
os.Exit(1)
}

fmt.Printf("\nSuccessfully uploaded %d worklog entries!\n", len(completeEntries))
}

func Execute(buildVersion string, buildCommit string, buildDate string) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package printer
package utils

import (
"fmt"
Expand All @@ -8,8 +8,6 @@ import (

"github.com/spf13/cobra"

"github.com/gabor-boros/minutes/internal/cmd/utils"

"github.com/gabor-boros/minutes/internal/pkg/worklog"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
Expand Down Expand Up @@ -102,10 +100,10 @@ func (p *tablePrinter) convertEntryToRow(entry *worklog.Entry) table.Row {
timeSpent := entry.BillableDuration + entry.UnbillableDuration

return table.Row{
utils.Truncate(entry.Task.Name, p.truncateMap[ColumnTask]),
utils.Truncate(entry.Summary, p.truncateMap[ColumnSummary]),
utils.Truncate(entry.Project.Name, p.truncateMap[ColumnProject]),
utils.Truncate(entry.Client.Name, p.truncateMap[ColumnClient]),
Truncate(entry.Task.Name, p.truncateMap[ColumnTask]),
Truncate(entry.Summary, p.truncateMap[ColumnSummary]),
Truncate(entry.Project.Name, p.truncateMap[ColumnProject]),
Truncate(entry.Client.Name, p.truncateMap[ColumnClient]),
entryStart.Format(rowDateFormat),
entryStart.Add(timeSpent).Format(rowDateFormat),
entry.BillableDuration,
Expand Down Expand Up @@ -197,7 +195,7 @@ func ParseColumnConfigs(key string, hiddenColumns []string) []table.ColumnConfig
err := viper.UnmarshalKey(fmt.Sprintf(key, column), &columnConfig)
cobra.CheckErr(err)

if utils.IsSliceContains(column, hiddenColumns) {
if IsSliceContains(column, hiddenColumns) {
columnConfig.Hidden = true
}

Expand Down
30 changes: 30 additions & 0 deletions internal/cmd/utils/tracker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package utils

import (
"time"

"github.com/jedib0t/go-pretty/v6/progress"
)

// NewProgressWriter returns a pre-configured progress writer.
func NewProgressWriter(updateFrequency time.Duration) progress.Writer {
writer := progress.NewWriter()

writer.ShowTime(true)
writer.ShowTracker(false)
writer.ShowValue(false)

writer.SetAutoStop(true)
writer.SetTrackerPosition(progress.PositionRight)

writer.SetMessageWidth(50)
writer.SetUpdateFrequency(updateFrequency)

writer.Style().Colors = progress.StyleColorsDefault
writer.Style().Options.DoneString = "uploaded!"
writer.Style().Options.ErrorString = "failed! " // Have the same length as DoneString
writer.Style().Options.Separator = "\t"
writer.Style().Options.SnipIndicator = "..."

return writer
}
15 changes: 15 additions & 0 deletions internal/cmd/utils/tracker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package utils_test

import (
"reflect"
"testing"
"time"

"github.com/gabor-boros/minutes/internal/cmd/utils"
"github.com/stretchr/testify/require"
)

func TestNewProgressWriter(t *testing.T) {
progressWriter := utils.NewProgressWriter(time.Millisecond * 100)
require.Equal(t, "*progress.Progress", reflect.TypeOf(progressWriter).String())
}
8 changes: 7 additions & 1 deletion internal/pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"net/url"
"time"

"github.com/jedib0t/go-pretty/v6/progress"

"github.com/gabor-boros/minutes/internal/pkg/worklog"
)

Expand Down Expand Up @@ -95,14 +97,18 @@ type UploadOpts struct {
CreateMissingResources bool
// User represents the user in which name the time log will be uploaded.
User string
// ProgressWriter represents a writer that tracks the upload progress.
// In case the ProgressWriter is nil, that means the upload progress should
// not be tracked, hence, that's not an error.
ProgressWriter progress.Writer
}

// Uploader specifies the functions used to upload worklog entries.
type Uploader interface {
// UploadEntries to a given target.
// If the upload resulted in an error, the upload will stop and an error
// will return.
UploadEntries(ctx context.Context, entries []worklog.Entry, opts *UploadOpts) error
UploadEntries(ctx context.Context, entries []worklog.Entry, errChan chan error, opts *UploadOpts)
}

// FetchUploader is the combination of Fetcher and Uploader.
Expand Down
37 changes: 25 additions & 12 deletions internal/pkg/client/tempo/tempo.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strconv"
"time"

"github.com/jedib0t/go-pretty/v6/progress"

"github.com/gabor-boros/minutes/internal/pkg/client"
"github.com/gabor-boros/minutes/internal/pkg/worklog"
)
Expand Down Expand Up @@ -114,7 +116,7 @@ func (c *tempoClient) FetchEntries(ctx context.Context, opts *client.FetchOpts)
return entries, nil
}

func (c *tempoClient) uploadEntry(ctx context.Context, entry worklog.Entry, opts *client.UploadOpts, errChan chan error) {
func (c *tempoClient) uploadEntry(ctx context.Context, entry worklog.Entry, tracker *progress.Tracker, opts *client.UploadOpts, errChan chan error) {
billableDuration := entry.BillableDuration
unbillableDuration := entry.UnbillableDuration
totalTimeSpent := billableDuration + unbillableDuration
Expand All @@ -141,27 +143,38 @@ func (c *tempoClient) uploadEntry(ctx context.Context, entry worklog.Entry, opts
}

if _, err := client.SendRequest(ctx, http.MethodPost, PathWorklogCreate, uploadEntry, &c.opts.HTTPClientOptions); err != nil {
errChan <- err
if tracker != nil {
tracker.MarkAsErrored()
}

errChan <- fmt.Errorf("%v: %+v: %v", client.ErrUploadEntries, uploadEntry, err)
return
}

if tracker != nil {
tracker.Increment(1)
tracker.MarkAsDone()
}

errChan <- nil
}

func (c *tempoClient) UploadEntries(ctx context.Context, entries []worklog.Entry, opts *client.UploadOpts) error {
errChan := make(chan error)

func (c *tempoClient) UploadEntries(ctx context.Context, entries []worklog.Entry, errChan chan error, opts *client.UploadOpts) {
for _, entry := range entries {
go c.uploadEntry(ctx, entry, opts, errChan)
}
var tracker *progress.Tracker

if opts.ProgressWriter != nil {
tracker = &progress.Tracker{
Message: entry.Summary,
Total: 1,
Units: progress.UnitsDefault,
}

for i := 0; i < len(entries); i++ {
if err := <-errChan; err != nil {
return fmt.Errorf("%v: %v", client.ErrUploadEntries, err)
opts.ProgressWriter.AppendTracker(tracker)
}
}

return nil
go c.uploadEntry(ctx, entry, tracker, opts, errChan)
}
}

// NewClient returns a new Tempo client.
Expand Down
Loading

0 comments on commit d27c124

Please sign in to comment.