Skip to content

Commit

Permalink
feat: add gen subcommand
Browse files Browse the repository at this point in the history
  • Loading branch information
dhth committed Jun 21, 2024
1 parent f920790 commit 49a900a
Show file tree
Hide file tree
Showing 12 changed files with 379 additions and 114 deletions.
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ go install github.com/dhth/hours@latest
⚡️ Usage
---

> Newbie tip: If you want to see how `hours` works without having to track time,
> you can have it generate dummy data for you. See [here](#generate-dummy-data)
> for more details.
### TUI

Open the TUI by simply running `hours`. The TUI lets you do the following:
Expand Down Expand Up @@ -85,6 +89,11 @@ reported on the day it ends.*

![Usage](https://tools.dhruvs.space/images/hours/report-1.png)

Reports can also be viewed via an interactive interface using the
`--interactive`/`-i` flag.

![Usage](https://tools.dhruvs.space/images/hours/report-interactive-1.gif)

### Log

```bash
Expand All @@ -107,8 +116,13 @@ appear in the log for the day it ends.*

![Usage](https://tools.dhruvs.space/images/hours/log-1.png)

Statistics
---
Logs can also be viewed via an interactive interface using the
`--interactive`/`-i` flag.

![Usage](https://tools.dhruvs.space/images/hours/log-interactive-1.gif)


### Statistics

```bash
hours stats [flag] [arg]
Expand All @@ -122,7 +136,6 @@ Accepts an argument, which can be one of the following:
yest: show stats for yesterday
3d: show stats for the last 3 days (default)
week: show stats for the current week
month: show stats for the current month
date: show stats for a specific date (eg. "2024/06/08")
range: show stats for a specific date range (eg. "2024/06/08...2024/06/12")
all: show stats for all log entries
Expand All @@ -132,6 +145,22 @@ be considered in the stats for the day it ends.*

![Usage](https://tools.dhruvs.space/images/hours/stats-1.png)

Stats can also be viewed via an interactive interface using the
`--interactive`/`-i` flag.

![Usage](https://tools.dhruvs.space/images/hours/stats-interactive-1.gif)

### Generate Dummy Data

You can have `hours` generate dummy data for you, so you can play around with
it, and see if its approach of showing reports/logs/stats works for you. You can
do so using the `gen` subcommand.

```bash
hours gen --dbpath=/var/tmp/throwaway.db
```


📋 TUI Reference Manual
---

Expand Down
192 changes: 151 additions & 41 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package cmd

import (
"bufio"
"database/sql"
"errors"
"fmt"
"io/fs"
"math/rand"
"os"
"os/user"
"strings"

"github.com/dhth/hours/internal/ui"
"github.com/spf13/cobra"
Expand All @@ -24,13 +27,65 @@ var (
recordsInteractive bool
recordsOutputPlain bool
activeTemplate string
genNumDays uint8
genNumTasks uint8
)

func die(msg string, args ...any) {
fmt.Fprintf(os.Stderr, msg+"\n", args...)
os.Exit(1)
}

func setupDB() {

if dbPath == "" {
die("dbpath cannot be empty")
}

dbPathFull := expandTilde(dbPath)

var err error

_, err = os.Stat(dbPathFull)
if errors.Is(err, fs.ErrNotExist) {
db, err = getDB(dbPathFull)

if err != nil {
die(`Couldn't create hours' local database. This is a fatal error;
let %s know about this via %s.
Error: %s`,
author,
repoIssuesUrl,
err)
}

err = initDB(db)
if err != nil {
die(`Couldn't create hours' local database. This is a fatal error;
let %s know about this via %s.
Error: %s`,
author,
repoIssuesUrl,
err)
}
upgradeDB(db, 1)
} else {
db, err = getDB(dbPathFull)
if err != nil {
die(`Couldn't open hours' local database. This is a fatal error;
let %s know about this via %s.
Error: %s`,
author,
repoIssuesUrl,
err)
}
upgradeDBIfNeeded(db)
}
}

var rootCmd = &cobra.Command{
Use: "hours",
Short: "\"hours\" is a no-frills time tracking toolkit for the command line",
Expand All @@ -40,55 +95,81 @@ You can use "hours" to track time on your tasks, or view logs, reports, and
summary statistics for your tracked time.
`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if dbPath == "" {
die("dbpath cannot be empty")
if cmd.CalledAs() == "gen" {
return
}
setupDB()
},
Run: func(cmd *cobra.Command, args []string) {
ui.RenderUI(db)
},
}

dbPathFull := expandTilde(dbPath)
var generateCmd = &cobra.Command{
Use: "gen",
Short: "Generate dummy log entries",
Long: `Generate dummy log entries.
This is intended for new users of 'hours' so they can get a sense of its
capabilities without actually tracking any time. It's recommended to always use
this with a --dbpath/-d flag that points to a throwaway database.
`,
Run: func(cmd *cobra.Command, args []string) {
if genNumDays > 30 {
die("Maximum value for number of days is 30")
}
if genNumTasks > 20 {
die("Maximum value for number of days is 20")
}

var err error
dbPathFull := expandTilde(dbPath)

_, err = os.Stat(dbPathFull)
if errors.Is(err, fs.ErrNotExist) {
db, err = getDB(dbPathFull)
_, statErr := os.Stat(dbPathFull)
if statErr == nil {
die(`A file already exists at %s. Either delete it, or use a different path.
if err != nil {
die(`Couldn't create hours' local database. This is a fatal error;
let %s know about this via %s.
Tip: 'gen' should always be used on a throwaway database file.`, dbPathFull)
}

Error: %s`,
author,
repoIssuesUrl,
err)
}

err = initDB(db)
if err != nil {
die(`Couldn't create hours' local database. This is a fatal error;
let %s know about this via %s.
fmt.Print(ui.WarningStyle.Render(`
WARNING: You shouldn't run 'gen' on hours' actively used database as it'll
create dummy entries in it. You can run it out on a throwaway database by
passing a path for it via --dbpath/-d (use it for all further invocations of
'hours' as well).
`))
fmt.Print(`
The 'gen' subcommand is intended for new users of 'hours' so they can get a
sense of its capabilities without actually tracking any time.
---
`)
confirm := getConfirmation()
if !confirm {
fmt.Printf("\nIncorrect code; exiting\n")
os.Exit(1)
}

Error: %s`,
author,
repoIssuesUrl,
err)
}
upgradeDB(db, 1)
} else {
db, err = getDB(dbPathFull)
if err != nil {
die(`Couldn't open hours' local database. This is a fatal error;
setupDB()
genErr := ui.GenerateData(db, genNumDays, genNumTasks)
if genErr != nil {
die(`Something went wrong generating dummy data.
let %s know about this via %s.
Error: %s`,
author,
repoIssuesUrl,
err)
}
upgradeDBIfNeeded(db)
Error: %s`, author, repoIssuesUrl, genErr)
}
},
Run: func(cmd *cobra.Command, args []string) {
ui.RenderUI(db)
fmt.Printf(`
Successfully generated dummy data in the database file: %s
If this is not the default database file path, use --dbpath/-d with 'hours' when
you want to access the dummy data.
Go ahead and try the following!
hours --dbpath=%s
hours --dbpath=%s report week -i
hours --dbpath=%s log today -i
hours --dbpath=%s stats today -i
`, dbPath, dbPath, dbPath, dbPath, dbPath)
},
}

Expand Down Expand Up @@ -167,7 +248,6 @@ Accepts an argument, which can be one of the following:
yest: show stats for yesterday
3d: show stats for the last 3 days (default)
week: show stats for the current week
month: show stats for the current month
date: show stats for a specific date (eg. "2024/06/08")
range: show stats for a specific date range (eg. "2024/06/08...2024/06/12")
all: show stats for all log entries
Expand Down Expand Up @@ -210,6 +290,9 @@ Error: %s`, author, repoIssuesUrl, err)
defaultDBPath := fmt.Sprintf("%s/hours.db", currentUser.HomeDir)
rootCmd.PersistentFlags().StringVarP(&dbPath, "dbpath", "d", defaultDBPath, "location of hours' database file")

generateCmd.Flags().Uint8Var(&genNumDays, "num-days", 30, "number of days to generate fake data for")
generateCmd.Flags().Uint8Var(&genNumTasks, "num-tasks", 10, "number of tasks to generate fake data for")

reportCmd.Flags().BoolVarP(&reportAgg, "agg", "a", false, "whether to aggregate data by task for each day in report")
reportCmd.Flags().BoolVarP(&recordsInteractive, "interactive", "i", false, "whether to view report interactively")
reportCmd.Flags().BoolVarP(&recordsOutputPlain, "plain", "p", false, "whether to output report without any formatting")
Expand All @@ -223,6 +306,7 @@ Error: %s`, author, repoIssuesUrl, err)
activeCmd.Flags().StringVarP(&activeTemplate, "template", "t", ui.ActiveTaskPlaceholder,
fmt.Sprintf("string template to use for outputting active task; use \"%s\" as placeholder for the task", ui.ActiveTaskPlaceholder))

rootCmd.AddCommand(generateCmd)
rootCmd.AddCommand(reportCmd)
rootCmd.AddCommand(logCmd)
rootCmd.AddCommand(statsCmd)
Expand All @@ -234,6 +318,32 @@ Error: %s`, author, repoIssuesUrl, err)
func Execute() {
err := rootCmd.Execute()
if err != nil {
die("Something went wrong: %s\n", err)
die("Something went wrong: %s", err)
}
}

func getRandomChars(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz"

var code string
for i := 0; i < length; i++ {
code += string(charset[rand.Intn(len(charset))])
}
return code
}

func getConfirmation() bool {

code := getRandomChars(2)
reader := bufio.NewReader(os.Stdin)

fmt.Printf("Type %s to proceed: ", code)

response, err := reader.ReadString('\n')
if err != nil {
die("Something went wrong reading input: %s", err)
}
response = strings.TrimSpace(response)

return response == code
}
21 changes: 11 additions & 10 deletions internal/ui/date_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const (
)

var (
timePeriodNotValidErr = fmt.Errorf("time period is not valid; accepted format: %s or %s...%s", dateFormat, dateFormat, dateFormat)
timePeriodNotValidErr = fmt.Errorf("time period is not valid; accepted values: day, yest, week, 3d, date (eg. %s), or date range (eg. %s...%s)", dateFormat, dateFormat, dateFormat)
timePeriodTooLargeErr = fmt.Errorf("time period is too large; maximum number of days allowed (both inclusive): %d", timePeriodDaysUpperBound)
)

Expand All @@ -21,33 +21,33 @@ type timePeriod struct {
numDays int
}

func parseDateDuration(dateRange string) (timePeriod, error) {
func parseDateDuration(dateRange string) (timePeriod, bool) {
var tp timePeriod

elements := strings.Split(dateRange, "...")
if len(elements) != 2 {
return tp, timePeriodNotValidErr
return tp, false
}

start, err := time.ParseInLocation(string(dateFormat), elements[0], time.Local)
if err != nil {
return tp, timePeriodNotValidErr
return tp, false
}

end, err := time.ParseInLocation(string(dateFormat), elements[1], time.Local)
if err != nil {
return tp, timePeriodNotValidErr
return tp, false
}

if end.Sub(start) <= 0 {
return tp, timePeriodNotValidErr
return tp, false
}

tp.start = start
tp.end = end
tp.numDays = int(end.Sub(start).Hours()/24) + 1

return tp, nil
return tp, true
}

func getTimePeriod(period string, now time.Time, fullWeek bool) (timePeriod, error) {
Expand Down Expand Up @@ -92,9 +92,10 @@ func getTimePeriod(period string, now time.Time, fullWeek bool) (timePeriod, err

if strings.Contains(period, "...") {
var ts timePeriod
ts, err = parseDateDuration(period)
if err != nil {
return ts, err
var ok bool
ts, ok = parseDateDuration(period)
if !ok {
return ts, timePeriodNotValidErr
}
if ts.numDays > timePeriodDaysUpperBound {
return ts, timePeriodTooLargeErr
Expand Down
Loading

0 comments on commit 49a900a

Please sign in to comment.