dstlled-diff
(short for "distilled-diff") is a GitHub action that processes a
specific git revision range and generates a diff that only includes changes in
the signatures of "code constructs" (functions, methods, classes, traits,
interfaces, objects, type aliases, enums, etc.). It is powered by dstll.
The main goal of dstlled-diff
is to simplify the review of large structural
code changes by removing diff components that do not alter signatures.
👉 Consider this (fairly long) git diff:
expand
diff --git a/cmd/db_migrations_test.go b/cmd/db_migrations_test.go
deleted file mode 100644
index ba16c00..0000000
--- a/cmd/db_migrations_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package cmd
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestMigrationsAreSetupCorrectly(t *testing.T) {
- migrations := getMigrations()
- for i := 2; i <= latestDBVersion; i++ {
- m, ok := migrations[i]
- assert.True(t, ok)
- assert.NotEmpty(t, m)
- }
-}
diff --git a/cmd/root.go b/cmd/root.go
index c1916bc..b3f47d4 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,310 +1,386 @@
package cmd
import (
"bufio"
"database/sql"
"errors"
"fmt"
"io/fs"
"math/rand"
"os"
- "os/user"
+ "path/filepath"
"strings"
+ pers "github.com/dhth/hours/internal/persistence"
"github.com/dhth/hours/internal/ui"
"github.com/spf13/cobra"
)
const (
- author = "@dhth"
- repoIssuesUrl = "https://github.com/dhth/hours/issues"
+ defaultDBName = "hours.db"
+ author = "@dhth"
+ repoIssuesURL = "https://github.com/dhth/hours/issues"
+ numDaysThreshold = 30
+ numTasksThreshold = 20
)
var (
- dbPath string
- db *sql.DB
- reportAgg bool
- recordsInteractive bool
- recordsOutputPlain bool
- activeTemplate string
- genNumDays uint8
- genNumTasks uint8
- genSkipConfirmation bool
+ errCouldntGetHomeDir = errors.New("couldn't get home directory")
+ errDBFileExtIncorrect = errors.New("db file needs to end with .db")
+ errCouldntCreateDBDirectory = errors.New("couldn't create directory for database")
+ errCouldntCreateDB = errors.New("couldn't create database")
+ errCouldntInitializeDB = errors.New("couldn't initialize database")
+ errCouldntOpenDB = errors.New("couldn't open database")
+ errCouldntGenerateData = errors.New("couldn't generate dummy data")
+ errNumDaysExceedsThreshold = errors.New("number of days exceeds threshold")
+ errNumTasksExceedsThreshold = errors.New("number of tasks exceeds threshold")
+ errCouldntReadInput = errors.New("couldn't read input")
+ errIncorrectCodeEntered = errors.New("incorrect code entered")
+
+ msgReportIssue = fmt.Sprintf("This isn't supposed to happen; let %s know about this error via \n%s.", author, repoIssuesURL)
)
-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")
+func Execute() error {
+ rootCmd, err := NewRootCommand()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %s\n", err)
+ if errors.Is(err, errCouldntGetHomeDir) {
+ fmt.Printf("\n%s\n", msgReportIssue)
+ }
+ return err
}
- dbPathFull := expandTilde(dbPath)
+ err = rootCmd.Execute()
+ if errors.Is(err, errCouldntGenerateData) {
+ fmt.Printf("\n%s\n", msgReportIssue)
+ }
+ return err
+}
+func setupDB(dbPathFull string) (*sql.DB, error) {
+ var db *sql.DB
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)
+ dir := filepath.Dir(dbPathFull)
+ err = os.MkdirAll(dir, 0o755)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %s", errCouldntCreateDBDirectory, err.Error())
}
- err = initDB(db)
+ db, err = pers.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)
+ return nil, fmt.Errorf("%w: %s", errCouldntCreateDB, err.Error())
+ }
+
+ err = pers.InitDB(db)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %s", errCouldntInitializeDB, err.Error())
+ }
+ err = pers.UpgradeDB(db, 1)
+ if err != nil {
+ return nil, err
}
- upgradeDB(db, 1)
} else {
- db, err = getDB(dbPathFull)
+ db, err = pers.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)
+ return nil, fmt.Errorf("%w: %s", errCouldntOpenDB, err.Error())
+ }
+ err = pers.UpgradeDBIfNeeded(db)
+ if err != nil {
+ return nil, err
}
- upgradeDBIfNeeded(db)
}
+
+ return db, nil
}
-var rootCmd = &cobra.Command{
- Use: "hours",
- Short: "\"hours\" is a no-frills time tracking toolkit for the command line",
- Long: `"hours" is a no-frills time tracking toolkit for the command line.
+func NewRootCommand() (*cobra.Command, error) {
+ var (
+ userHomeDir string
+ dbPath string
+ dbPathFull string
+ db *sql.DB
+ reportAgg bool
+ recordsInteractive bool
+ recordsOutputPlain bool
+ activeTemplate string
+ genNumDays uint8
+ genNumTasks uint8
+ genSkipConfirmation bool
+ )
+
+ rootCmd := &cobra.Command{
+ Use: "hours",
+ Short: "\"hours\" is a no-frills time tracking toolkit for the command line",
+ Long: `"hours" is a no-frills time tracking toolkit for the command line.
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 cmd.CalledAs() == "gen" {
- return
- }
- setupDB()
- },
- Run: func(cmd *cobra.Command, args []string) {
- ui.RenderUI(db)
- },
-}
+ SilenceUsage: true,
+ PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
+ if cmd.CalledAs() == "updates" {
+ return nil
+ }
-var generateCmd = &cobra.Command{
- Use: "gen",
- Short: "Generate dummy log entries (helpful for beginners)",
- Long: `Generate dummy log entries.
+ dbPathFull = expandTilde(dbPath, userHomeDir)
+ if filepath.Ext(dbPathFull) != ".db" {
+ return errDBFileExtIncorrect
+ }
+
+ var err error
+ db, err = setupDB(dbPathFull)
+ switch {
+ case errors.Is(err, errCouldntCreateDB):
+ fmt.Fprintf(os.Stderr, `Couldn't create omm's local database.
+%s
+
+`, msgReportIssue)
+ case errors.Is(err, errCouldntInitializeDB):
+ fmt.Fprintf(os.Stderr, `Couldn't initialise omm's local database.
+%s
+
+`, msgReportIssue)
+ // cleanup
+ cleanupErr := os.Remove(dbPathFull)
+ if cleanupErr != nil {
+ fmt.Fprintf(os.Stderr, `Failed to remove omm's database file as well (at %s). Remove it manually.
+Clean up error: %s
+
+`, dbPathFull, cleanupErr.Error())
+ }
+ case errors.Is(err, errCouldntOpenDB):
+ fmt.Fprintf(os.Stderr, `Couldn't open omm's local database.
+%s
+
+`, msgReportIssue)
+ case errors.Is(err, pers.ErrCouldntFetchDBVersion):
+ fmt.Fprintf(os.Stderr, `Couldn't get omm's latest database version.
+%s
+
+`, msgReportIssue)
+ case errors.Is(err, pers.ErrDBDowngraded):
+ fmt.Fprintf(os.Stderr, `Looks like you downgraded omm. You should either delete omm's database file (you
+will lose data by doing that), or upgrade omm to the latest version.
+
+`)
+ case errors.Is(err, pers.ErrDBMigrationFailed):
+ fmt.Fprintf(os.Stderr, `Something went wrong migrating omm's database.
+
+You can try running omm by passing it a custom database file path (using
+--db-path; this will create a new database) to see if that fixes things. If that
+works, you can either delete the previous database, or keep using this new
+database (both are not ideal).
+
+%s
+Sorry for breaking the upgrade step!
+
+---
+
+`, msgReportIssue)
+ }
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+ },
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return ui.RenderUI(db)
+ },
+ }
+
+ generateCmd := &cobra.Command{
+ Use: "gen",
+ Short: "Generate dummy log entries (helpful for beginners)",
+ 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")
- }
+ RunE: func(_ *cobra.Command, _ []string) error {
+ if genNumDays > numDaysThreshold {
+ return fmt.Errorf("%w (%d)", errNumDaysExceedsThreshold, numDaysThreshold)
+ }
+ if genNumTasks > numTasksThreshold {
+ return fmt.Errorf("%w (%d)", errNumTasksExceedsThreshold, numTasksThreshold)
+ }
- dbPathFull := expandTilde(dbPath)
-
- _, statErr := os.Stat(dbPathFull)
- if statErr == nil {
- die(`A file already exists at %s. Either delete it, or use a different path.
-
-Tip: 'gen' should always be used on a throwaway database file.`, dbPathFull)
- }
-
- if !genSkipConfirmation {
- fmt.Print(ui.WarningStyle.Render(`
+ if !genSkipConfirmation {
+ 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).
+create dummy entries in it. You can run it 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(`
+ 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)
+ confirm, err := getConfirmation()
+ if err != nil {
+ return err
+ }
+ if !confirm {
+ return fmt.Errorf("%w", errIncorrectCodeEntered)
+ }
}
- }
- 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, genErr)
- }
- fmt.Printf(`
+ genErr := ui.GenerateData(db, genNumDays, genNumTasks)
+ if genErr != nil {
+ return fmt.Errorf("%w: %s", errCouldntGenerateData, genErr.Error())
+ }
+ 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)
- },
-}
+ return nil
+ },
+ }
-var reportCmd = &cobra.Command{
- Use: "report",
- Short: "Output a report based on task log entries",
- Long: `Output a report based on task log entries.
+ reportCmd := &cobra.Command{
+ Use: "report",
+ Short: "Output a report based on task log entries",
+ Long: `Output a report based on task log entries.
Reports show time spent on tasks per day in the time period you specify. These
can also be aggregated (using -a) to consolidate all task entries and show the
cumulative time spent on each task per day.
Accepts an argument, which can be one of the following:
today: for today's report
yest: for yesterday's report
3d: for a report on the last 3 days (default)
week: for a report on the current week
date: for a report for a specific date (eg. "2024/06/08")
range: for a report for a date range (eg. "2024/06/08...2024/06/12")
Note: If a task log continues past midnight in your local timezone, it
will be reported on the day it ends.
`,
- Args: cobra.MaximumNArgs(1),
- Run: func(cmd *cobra.Command, args []string) {
- var period string
- if len(args) == 0 {
- period = "3d"
- } else {
- period = args[0]
- }
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(_ *cobra.Command, args []string) error {
+ var period string
+ if len(args) == 0 {
+ period = "3d"
+ } else {
+ period = args[0]
+ }
- ui.RenderReport(db, os.Stdout, recordsOutputPlain, period, reportAgg, recordsInteractive)
- },
-}
+ return ui.RenderReport(db, os.Stdout, recordsOutputPlain, period, reportAgg, recordsInteractive)
+ },
+ }
-var logCmd = &cobra.Command{
- Use: "log",
- Short: "Output task log entries",
- Long: `Output task log entries.
+ logCmd := &cobra.Command{
+ Use: "log",
+ Short: "Output task log entries",
+ Long: `Output task log entries.
Accepts an argument, which can be one of the following:
today: for log entries from today (default)
yest: for log entries from yesterday
3d: for log entries from the last 3 days
week: for log entries from the current week
date: for log entries from a specific date (eg. "2024/06/08")
range: for log entries from a specific date range (eg. "2024/06/08...2024/06/12")
Note: If a task log continues past midnight in your local timezone, it'll
appear in the log for the day it ends.
`,
- Args: cobra.MaximumNArgs(1),
- Run: func(cmd *cobra.Command, args []string) {
- var period string
- if len(args) == 0 {
- period = "today"
- } else {
- period = args[0]
- }
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(_ *cobra.Command, args []string) error {
+ var period string
+ if len(args) == 0 {
+ period = "today"
+ } else {
+ period = args[0]
+ }
- ui.RenderTaskLog(db, os.Stdout, recordsOutputPlain, period, recordsInteractive)
- },
-}
+ return ui.RenderTaskLog(db, os.Stdout, recordsOutputPlain, period, recordsInteractive)
+ },
+ }
-var statsCmd = &cobra.Command{
- Use: "stats",
- Short: "Output statistics for tracked time",
- Long: `Output statistics for tracked time.
+ statsCmd := &cobra.Command{
+ Use: "stats",
+ Short: "Output statistics for tracked time",
+ Long: `Output statistics for tracked time.
Accepts an argument, which can be one of the following:
today: show stats for today
yest: show stats for yesterday
3d: show stats for the last 3 days (default)
week: show stats for the current week
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
Note: If a task log continues past midnight in your local timezone, it'll
be considered in the stats for the day it ends.
`,
- Args: cobra.MaximumNArgs(1),
- Run: func(cmd *cobra.Command, args []string) {
- var period string
- if len(args) == 0 {
- period = "3d"
- } else {
- period = args[0]
- }
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(_ *cobra.Command, args []string) error {
+ var period string
+ if len(args) == 0 {
+ period = "3d"
+ } else {
+ period = args[0]
+ }
- ui.RenderStats(db, os.Stdout, recordsOutputPlain, period, recordsInteractive)
- },
-}
+ return ui.RenderStats(db, os.Stdout, recordsOutputPlain, period, recordsInteractive)
+ },
+ }
-var activeCmd = &cobra.Command{
- Use: "active",
- Short: "Show the task being actively tracked by \"hours\"",
- Long: `Show the task being actively tracked by "hours".
+ activeCmd := &cobra.Command{
+ Use: "active",
+ Short: "Show the task being actively tracked by \"hours\"",
+ Long: `Show the task being actively tracked by "hours".
You can pass in a template using the --template/-t flag, which supports the
following placeholders:
{{task}}: for the task summary
{{time}}: for the time spent so far on the active log entry
eg. hours active -t ' {{task}} ({{time}}) '
`,
- Run: func(cmd *cobra.Command, args []string) {
- ui.ShowActiveTask(db, os.Stdout, activeTemplate)
- },
-}
-
-func init() {
- currentUser, err := user.Current()
- if err != nil {
- die(`Couldn't get your home directory. This is a fatal error;
-use --dbpath to specify database path manually
-let %s know about this via %s.
-
-Error: %s`, author, repoIssuesUrl, err)
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return ui.ShowActiveTask(db, os.Stdout, activeTemplate)
+ },
}
- defaultDBPath := fmt.Sprintf("%s/hours.db", currentUser.HomeDir)
+ var err error
+ userHomeDir, err = os.UserHomeDir()
+ if err != nil {
+ return nil, fmt.Errorf("%w: %s", errCouldntGetHomeDir, err.Error())
+ }
+
+ defaultDBPath := filepath.Join(userHomeDir, defaultDBName)
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")
generateCmd.Flags().BoolVarP(&genSkipConfirmation, "yes", "y", false, "to skip confirmation")
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")
@@ -316,43 +392,38 @@ Error: %s`, author, repoIssuesUrl, err)
activeCmd.Flags().StringVarP(&activeTemplate, "template", "t", ui.ActiveTaskPlaceholder, "string template to use for outputting active task")
rootCmd.AddCommand(generateCmd)
rootCmd.AddCommand(reportCmd)
rootCmd.AddCommand(logCmd)
rootCmd.AddCommand(statsCmd)
rootCmd.AddCommand(activeCmd)
rootCmd.CompletionOptions.DisableDefaultCmd = true
-}
-func Execute() {
- err := rootCmd.Execute()
- if err != nil {
- die("Something went wrong: %s", err)
- }
+ return rootCmd, nil
}
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 {
+func getConfirmation() (bool, error) {
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)
+ return false, fmt.Errorf("%w: %s", errCouldntReadInput, err.Error())
}
response = strings.TrimSpace(response)
- return response == code
+ return response == code, nil
}
diff --git a/cmd/utils.go b/cmd/utils.go
index ea1318e..62349f7 100644
--- a/cmd/utils.go
+++ b/cmd/utils.go
@@ -1,18 +1,14 @@
package cmd
import (
- "os"
- "os/user"
+ "path/filepath"
"strings"
)
-func expandTilde(path string) string {
- if strings.HasPrefix(path, "~") {
- usr, err := user.Current()
- if err != nil {
- os.Exit(1)
- }
- return strings.Replace(path, "~", usr.HomeDir, 1)
+func expandTilde(path string, homeDir string) string {
+ pathWithoutTilde, found := strings.CutPrefix(path, "~/")
+ if !found {
+ return path
}
- return path
+ return filepath.Join(homeDir, pathWithoutTilde)
}
diff --git a/cmd/utils_test.go b/cmd/utils_test.go
new file mode 100644
index 0000000..9d73a0d
--- /dev/null
+++ b/cmd/utils_test.go
@@ -0,0 +1,37 @@
+package cmd
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExpandTilde(t *testing.T) {
+ testCases := []struct {
+ name string
+ path string
+ homeDir string
+ expected string
+ }{
+ {
+ name: "a simple case",
+ path: "~/some/path",
+ homeDir: "/Users/trinity",
+ expected: "/Users/trinity/some/path",
+ },
+ {
+ name: "path with no ~",
+ path: "some/path",
+ homeDir: "/Users/trinity",
+ expected: "some/path",
+ },
+ }
+
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ got := expandTilde(tt.path, tt.homeDir)
+
+ assert.Equal(t, tt.expected, got)
+ })
+ }
+}
diff --git a/cmd/db.go b/internal/persistence/init.go
similarity index 87%
rename from cmd/db.go
rename to internal/persistence/init.go
index bb572d5..3d71182 100644
--- a/cmd/db.go
+++ b/internal/persistence/init.go
@@ -1,25 +1,18 @@
-package cmd
+package persistence
import (
"database/sql"
"time"
)
-func getDB(dbpath string) (*sql.DB, error) {
- db, err := sql.Open("sqlite", dbpath)
- db.SetMaxOpenConns(1)
- db.SetMaxIdleConns(1)
- return db, err
-}
-
-func initDB(db *sql.DB) error {
+func InitDB(db *sql.DB) error {
// these init queries cannot be changed
// once hours is released; only further migrations
// can be added, which are run whenever hours
// sees a difference between the values in db_versions
// and latestDBVersion
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS db_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
diff --git a/cmd/db_migrations.go b/internal/persistence/migrations.go
similarity index 57%
rename from cmd/db_migrations.go
rename to internal/persistence/migrations.go
index e36e1b7..d10f004 100644
--- a/cmd/db_migrations.go
+++ b/internal/persistence/migrations.go
@@ -1,35 +1,41 @@
-package cmd
+package persistence
import (
"database/sql"
+ "errors"
+ "fmt"
"time"
)
-const (
- latestDBVersion = 1 // only upgrade this after adding a migration in getMigrations
+const latestDBVersion = 1 // only upgrade this after adding a migration in getMigrations
+
+var (
+ ErrDBDowngraded = errors.New("database downgraded")
+ ErrDBMigrationFailed = errors.New("database migration failed")
+ ErrCouldntFetchDBVersion = errors.New("couldn't fetch version")
)
type dbVersionInfo struct {
id int
version int
createdAt time.Time
}
func getMigrations() map[int]string {
migrations := make(map[int]string)
// these migrations should not be modified once released.
// that is, migrations is an append-only map.
// migrations[2] = `
// ALTER TABLE task
- // ADD COLUMN a_col INTEGER NOT NULL DEFAULT 1;
+ // ADD COLUMN new_col TEXT;
// `
return migrations
}
func fetchLatestDBVersion(db *sql.DB) (dbVersionInfo, error) {
row := db.QueryRow(`
SELECT id, version, created_at
FROM db_versions
ORDER BY created_at DESC
@@ -39,65 +45,54 @@ LIMIT 1;
var dbVersion dbVersionInfo
err := row.Scan(
&dbVersion.id,
&dbVersion.version,
&dbVersion.createdAt,
)
return dbVersion, err
}
-func upgradeDBIfNeeded(db *sql.DB) {
- latestVersionInDB, versionErr := fetchLatestDBVersion(db)
- if versionErr != nil {
- die(`Couldn't get hours' latest database version. This is a fatal error; let %s
-know about this via %s.
-
-Error: %s`,
- author,
- repoIssuesUrl,
- versionErr)
+func UpgradeDBIfNeeded(db *sql.DB) error {
+ latestVersionInDB, err := fetchLatestDBVersion(db)
+ if err != nil {
+ return fmt.Errorf("%w: %s", ErrCouldntFetchDBVersion, err.Error())
}
if latestVersionInDB.version > latestDBVersion {
- die(`Looks like you downgraded hours. You should either delete hours'
-database file (you will lose data by doing that), or upgrade hours to
-the latest version.`)
+ return fmt.Errorf("%w; debug info: version=%d, created at=%q)",
+ ErrDBDowngraded,
+ latestVersionInDB.version,
+ latestVersionInDB.createdAt.Format(time.RFC3339),
+ )
}
if latestVersionInDB.version < latestDBVersion {
- upgradeDB(db, latestVersionInDB.version)
+ err = UpgradeDB(db, latestVersionInDB.version)
+ if err != nil {
+ return err
+ }
}
+
+ return nil
}
-func upgradeDB(db *sql.DB, currentVersion int) {
+func UpgradeDB(db *sql.DB, currentVersion int) error {
migrations := getMigrations()
for i := currentVersion + 1; i <= latestDBVersion; i++ {
migrateQuery := migrations[i]
migrateErr := runMigration(db, migrateQuery, i)
if migrateErr != nil {
- die(`Something went wrong migrating hours' database to version %d. This is not
-supposed to happen. You can try running hours by passing it a custom database
-file path (using --dbpath; this will create a new database) to see if that fixes
-things. If that works, you can either delete the previous database, or keep
-using this new database (both are not ideal).
-
-If you can, let %s know about this error via
-%s.
-Sorry for breaking the upgrade step!
-
----
-
-Error: %s
-`, i, author, repoIssuesUrl, migrateErr)
+ return fmt.Errorf("%w (version %d): %v", ErrDBMigrationFailed, i, migrateErr.Error())
}
}
+ return nil
}
func runMigration(db *sql.DB, migrateQuery string, version int) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
diff --git a/internal/persistence/migrations_test.go b/internal/persistence/migrations_test.go
new file mode 100644
index 0000000..f97ac67
--- /dev/null
+++ b/internal/persistence/migrations_test.go
@@ -0,0 +1,69 @@
+package persistence
+
+import (
+ "database/sql"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ _ "modernc.org/sqlite" // sqlite driver
+)
+
+func TestMigrationsAreSetupCorrectly(t *testing.T) {
+ // GIVEN
+ // WHEN
+ migrations := getMigrations()
+
+ // THEN
+ for i := 2; i <= latestDBVersion; i++ {
+ m, ok := migrations[i]
+ if !ok {
+ assert.True(t, ok, "couldn't get migration %d", i)
+ }
+ if m == "" {
+ assert.NotEmpty(t, ok, "migration %d is empty", i)
+ }
+ }
+}
+
+func TestMigrationsWork(t *testing.T) {
+ // GIVEN
+ var testDB *sql.DB
+ var err error
+ testDB, err = sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatalf("Couldn't open database: %s", err.Error())
+ }
+
+ err = InitDB(testDB)
+ if err != nil {
+ t.Fatalf("Couldn't initialize database: %s", err.Error())
+ }
+
+ // WHEN
+ err = UpgradeDB(testDB, 1)
+
+ // THEN
+ assert.NoError(t, err)
+}
+
+func TestRunMigrationFailsWhenGivenBadMigration(t *testing.T) {
+ // GIVEN
+ var testDB *sql.DB
+ var err error
+ testDB, err = sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatalf("Couldn't open database: %s", err.Error())
+ }
+
+ err = InitDB(testDB)
+ if err != nil {
+ t.Fatalf("Couldn't initialize database: %s", err.Error())
+ }
+
+ // WHEN
+ query := "BAD SQL CODE;"
+ migrateErr := runMigration(testDB, query, 1)
+
+ // THEN
+ assert.Error(t, migrateErr)
+}
diff --git a/internal/persistence/open.go b/internal/persistence/open.go
new file mode 100644
index 0000000..6251f9f
--- /dev/null
+++ b/internal/persistence/open.go
@@ -0,0 +1,12 @@
+package persistence
+
+import (
+ "database/sql"
+)
+
+func GetDB(dbpath string) (*sql.DB, error) {
+ db, err := sql.Open("sqlite", dbpath)
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+ return db, err
+}
diff --git a/internal/persistence/queries.go b/internal/persistence/queries.go
new file mode 100644
index 0000000..73f6b05
--- /dev/null
+++ b/internal/persistence/queries.go
@@ -0,0 +1,615 @@
+package persistence
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/dhth/hours/internal/types"
+)
+
+var ErrCouldntRollBackTx = errors.New("couldn't roll back transaction")
+
+func InsertNewTL(db *sql.DB, taskID int, beginTs time.Time) (int, error) {
+ return runInTxAndReturnID(db, func(tx *sql.Tx) (int, error) {
+ stmt, err := tx.Prepare(`
+INSERT INTO task_log (task_id, begin_ts, active)
+VALUES (?, ?, ?);
+`)
+ if err != nil {
+ return -1, err
+ }
+ defer stmt.Close()
+
+ res, err := stmt.Exec(taskID, beginTs.UTC(), true)
+ if err != nil {
+ return -1, err
+ }
+
+ lastID, err := res.LastInsertId()
+ if err != nil {
+ return -1, err
+ }
+
+ return int(lastID), nil
+ })
+}
+
+func UpdateTLBeginTS(db *sql.DB, beginTs time.Time) error {
+ stmt, err := db.Prepare(`
+UPDATE task_log SET begin_ts=?
+WHERE active is true;
+`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ _, err = stmt.Exec(beginTs.UTC(), true)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func DeleteActiveTL(db *sql.DB) error {
+ stmt, err := db.Prepare(`
+DELETE FROM task_log
+WHERE active=true;
+`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ _, err = stmt.Exec()
+
+ return err
+}
+
+func UpdateActiveTL(db *sql.DB, taskLogID int, taskID int, beginTs, endTs time.Time, secsSpent int, comment string) error {
+ return runInTx(db, func(tx *sql.Tx) error {
+ stmt, err := tx.Prepare(`
+UPDATE task_log
+SET active = 0,
+ begin_ts = ?,
+ end_ts = ?,
+ secs_spent = ?,
+ comment = ?
+WHERE id = ?
+AND active = 1;
+`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ _, err = stmt.Exec(beginTs.UTC(), endTs.UTC(), secsSpent, comment, taskLogID)
+ if err != nil {
+ return err
+ }
+
+ tStmt, err := tx.Prepare(`
+UPDATE task
+SET secs_spent = secs_spent+?,
+ updated_at = ?
+WHERE id = ?;
+ `)
+ if err != nil {
+ return err
+ }
+ defer tStmt.Close()
+
+ _, err = tStmt.Exec(secsSpent, time.Now().UTC(), taskID)
+
+ return err
+ })
+}
+
+func InsertManualTL(db *sql.DB, taskID int, beginTs time.Time, endTs time.Time, comment string) (int, error) {
+ return runInTxAndReturnID(db, func(tx *sql.Tx) (int, error) {
+ stmt, err := tx.Prepare(`
+INSERT INTO task_log (task_id, begin_ts, end_ts, secs_spent, comment, active)
+VALUES (?, ?, ?, ?, ?, ?);
+`)
+ if err != nil {
+ return -1, err
+ }
+ defer stmt.Close()
+
+ secsSpent := int(endTs.Sub(beginTs).Seconds())
+
+ res, err := stmt.Exec(taskID, beginTs.UTC(), endTs.UTC(), secsSpent, comment, false)
+ if err != nil {
+ return -1, err
+ }
+
+ lastID, err := res.LastInsertId()
+ if err != nil {
+ return -1, err
+ }
+
+ tStmt, err := tx.Prepare(`
+UPDATE task
+SET secs_spent = secs_spent+?,
+ updated_at = ?
+WHERE id = ?;
+ `)
+ if err != nil {
+ return -1, err
+ }
+ defer tStmt.Close()
+
+ _, err = tStmt.Exec(secsSpent, time.Now().UTC(), taskID)
+ if err != nil {
+ return -1, err
+ }
+
+ return int(lastID), nil
+ })
+}
+
+func FetchActiveTask(db *sql.DB) (types.ActiveTaskDetails, error) {
+ row := db.QueryRow(`
+SELECT t.id, t.summary, tl.begin_ts
+FROM task_log tl left join task t on tl.task_id = t.id
+WHERE tl.active=true;
+`)
+
+ var activeTaskDetails types.ActiveTaskDetails
+ err := row.Scan(
+ &activeTaskDetails.TaskID,
+ &activeTaskDetails.TaskSummary,
+ &activeTaskDetails.LastLogEntryBeginTS,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ activeTaskDetails.TaskID = -1
+ return activeTaskDetails, nil
+ } else if err != nil {
+ return activeTaskDetails, err
+ }
+ activeTaskDetails.LastLogEntryBeginTS = activeTaskDetails.LastLogEntryBeginTS.Local()
+ return activeTaskDetails, nil
+}
+
+func InsertTask(db *sql.DB, summary string) (int, error) {
+ return runInTxAndReturnID(db, func(tx *sql.Tx) (int, error) {
+ stmt, err := tx.Prepare(`
+INSERT into task (summary, active, created_at, updated_at)
+VALUES (?, true, ?, ?);
+`)
+ if err != nil {
+ return -1, err
+ }
+ defer stmt.Close()
+
+ now := time.Now().UTC()
+ res, err := stmt.Exec(summary, now, now)
+ if err != nil {
+ return -1, err
+ }
+
+ lastID, err := res.LastInsertId()
+ if err != nil {
+ return -1, err
+ }
+
+ return int(lastID), nil
+ })
+}
+
+func UpdateTask(db *sql.DB, id int, summary string) error {
+ stmt, err := db.Prepare(`
+UPDATE task
+SET summary = ?,
+ updated_at = ?
+WHERE id = ?
+`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ _, err = stmt.Exec(summary, time.Now().UTC(), id)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func UpdateTaskActiveStatus(db *sql.DB, id int, active bool) error {
+ stmt, err := db.Prepare(`
+UPDATE task
+SET active = ?,
+ updated_at = ?
+WHERE id = ?
+`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ _, err = stmt.Exec(active, time.Now().UTC(), id)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func UpdateTaskData(db *sql.DB, t *types.Task) error {
+ row := db.QueryRow(`
+SELECT secs_spent, updated_at
+FROM task
+WHERE id=?;
+ `, t.ID)
+
+ err := row.Scan(
+ &t.SecsSpent,
+ &t.UpdatedAt,
+ )
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func FetchTasks(db *sql.DB, active bool, limit int) ([]types.Task, error) {
+ var tasks []types.Task
+
+ rows, err := db.Query(`
+SELECT id, summary, secs_spent, created_at, updated_at, active
+FROM task
+WHERE active=?
+ORDER by updated_at DESC
+LIMIT ?;
+ `, active, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var entry types.Task
+ err = rows.Scan(&entry.ID,
+ &entry.Summary,
+ &entry.SecsSpent,
+ &entry.CreatedAt,
+ &entry.UpdatedAt,
+ &entry.Active,
+ )
+ if err != nil {
+ return nil, err
+ }
+ entry.CreatedAt = entry.CreatedAt.Local()
+ entry.UpdatedAt = entry.UpdatedAt.Local()
+ tasks = append(tasks, entry)
+
+ }
+ if rows.Err() != nil {
+ return nil, err
+ }
+ return tasks, nil
+}
+
+func FetchTLEntries(db *sql.DB, desc bool, limit int) ([]types.TaskLogEntry, error) {
+ var logEntries []types.TaskLogEntry
+
+ var order string
+ if desc {
+ order = "DESC"
+ } else {
+ order = "ASC"
+ }
+ query := fmt.Sprintf(`
+SELECT tl.id, tl.task_id, t.summary, tl.begin_ts, tl.end_ts, tl.secs_spent, tl.comment
+FROM task_log tl left join task t on tl.task_id=t.id
+WHERE tl.active=false
+ORDER by tl.begin_ts %s
+LIMIT ?;
+`, order)
+
+ rows, err := db.Query(query, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var entry types.TaskLogEntry
+ err = rows.Scan(&entry.ID,
+ &entry.TaskID,
+ &entry.TaskSummary,
+ &entry.BeginTS,
+ &entry.EndTS,
+ &entry.SecsSpent,
+ &entry.Comment,
+ )
+ if err != nil {
+ return nil, err
+ }
+ entry.BeginTS = entry.BeginTS.Local()
+ entry.EndTS = entry.EndTS.Local()
+ logEntries = append(logEntries, entry)
+
+ }
+ if rows.Err() != nil {
+ return nil, err
+ }
+ return logEntries, nil
+}
+
+func FetchTLEntriesBetweenTS(db *sql.DB, beginTs, endTs time.Time, limit int) ([]types.TaskLogEntry, error) {
+ var logEntries []types.TaskLogEntry
+
+ rows, err := db.Query(`
+SELECT tl.id, tl.task_id, t.summary, tl.begin_ts, tl.end_ts, tl.secs_spent, tl.comment
+FROM task_log tl left join task t on tl.task_id=t.id
+WHERE tl.active=false
+AND tl.end_ts >= ?
+AND tl.end_ts < ?
+ORDER by tl.begin_ts ASC LIMIT ?;
+ `, beginTs.UTC(), endTs.UTC(), limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var entry types.TaskLogEntry
+ err = rows.Scan(&entry.ID,
+ &entry.TaskID,
+ &entry.TaskSummary,
+ &entry.BeginTS,
+ &entry.EndTS,
+ &entry.SecsSpent,
+ &entry.Comment,
+ )
+ if err != nil {
+ return nil, err
+ }
+ entry.BeginTS = entry.BeginTS.Local()
+ entry.EndTS = entry.EndTS.Local()
+ logEntries = append(logEntries, entry)
+
+ }
+ if rows.Err() != nil {
+ return nil, err
+ }
+ return logEntries, nil
+}
+
+func FetchStats(db *sql.DB, limit int) ([]types.TaskReportEntry, error) {
+ rows, err := db.Query(`
+SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries, t.secs_spent
+from task_log tl
+LEFT JOIN task t on tl.task_id = t.id
+GROUP BY tl.task_id
+ORDER BY t.secs_spent DESC
+limit ?;
+`, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tLE []types.TaskReportEntry
+
+ for rows.Next() {
+ var entry types.TaskReportEntry
+ err = rows.Scan(
+ &entry.TaskID,
+ &entry.TaskSummary,
+ &entry.NumEntries,
+ &entry.SecsSpent,
+ )
+ if err != nil {
+ return nil, err
+ }
+ tLE = append(tLE, entry)
+
+ }
+ if rows.Err() != nil {
+ return nil, err
+ }
+ return tLE, nil
+}
+
+func FetchStatsBetweenTS(db *sql.DB, beginTs, endTs time.Time, limit int) ([]types.TaskReportEntry, error) {
+ rows, err := db.Query(`
+SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries, SUM(tl.secs_spent) AS secs_spent
+FROM task_log tl
+LEFT JOIN task t ON tl.task_id = t.id
+WHERE tl.end_ts >= ? AND tl.end_ts < ?
+GROUP BY tl.task_id
+ORDER BY secs_spent DESC
+LIMIT ?;
+`, beginTs.UTC(), endTs.UTC(), limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tLE []types.TaskReportEntry
+
+ for rows.Next() {
+ var entry types.TaskReportEntry
+ err = rows.Scan(
+ &entry.TaskID,
+ &entry.TaskSummary,
+ &entry.NumEntries,
+ &entry.SecsSpent,
+ )
+ if err != nil {
+ return nil, err
+ }
+ tLE = append(tLE, entry)
+
+ }
+ if rows.Err() != nil {
+ return nil, err
+ }
+ return tLE, nil
+}
+
+func FetchReportBetweenTS(db *sql.DB, beginTs, endTs time.Time, limit int) ([]types.TaskReportEntry, error) {
+ rows, err := db.Query(`
+SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries, SUM(tl.secs_spent) AS secs_spent
+FROM task_log tl
+LEFT JOIN task t ON tl.task_id = t.id
+WHERE tl.end_ts >= ? AND tl.end_ts < ?
+GROUP BY tl.task_id
+ORDER BY t.updated_at ASC
+LIMIT ?;
+`, beginTs.UTC(), endTs.UTC(), limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tLE []types.TaskReportEntry
+
+ for rows.Next() {
+ var entry types.TaskReportEntry
+ err = rows.Scan(
+ &entry.TaskID,
+ &entry.TaskSummary,
+ &entry.NumEntries,
+ &entry.SecsSpent,
+ )
+ if err != nil {
+ return nil, err
+ }
+ tLE = append(tLE, entry)
+
+ }
+ if rows.Err() != nil {
+ return nil, err
+ }
+ return tLE, nil
+}
+
+func DeleteTaskLogEntry(db *sql.DB, entry *types.TaskLogEntry) error {
+ return runInTx(db, func(tx *sql.Tx) error {
+ stmt, err := tx.Prepare(`
+DELETE from task_log
+WHERE ID=?;
+`)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ _, err = stmt.Exec(entry.ID)
+ if err != nil {
+ return err
+ }
+
+ tStmt, err := tx.Prepare(`
+UPDATE task
+SET secs_spent = secs_spent-?,
+ updated_at = ?
+WHERE id = ?;
+ `)
+ if err != nil {
+ return err
+ }
+ defer tStmt.Close()
+
+ _, err = tStmt.Exec(entry.SecsSpent, time.Now().UTC(), entry.TaskID)
+ return err
+ })
+}
+
+func runInTxAndReturnID(db *sql.DB, fn func(tx *sql.Tx) (int, error)) (int, error) {
+ tx, err := db.Begin()
+ if err != nil {
+ return -1, err
+ }
+
+ lastID, err := fn(tx)
+ if err == nil {
+ return lastID, tx.Commit()
+ }
+
+ rollbackErr := tx.Rollback()
+ if rollbackErr != nil {
+ return lastID, fmt.Errorf("%w: %w: %s", ErrCouldntRollBackTx, rollbackErr, err.Error())
+ }
+
+ return lastID, err
+}
+
+func runInTx(db *sql.DB, fn func(tx *sql.Tx) error) error {
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+
+ err = fn(tx)
+ if err == nil {
+ return tx.Commit()
+ }
+
+ rollbackErr := tx.Rollback()
+ if rollbackErr != nil {
+ return fmt.Errorf("%w: %w: %w", ErrCouldntRollBackTx, rollbackErr, err)
+ }
+
+ return err
+}
+
+func fetchTaskByID(db *sql.DB, id int) (types.Task, error) {
+ var task types.Task
+ row := db.QueryRow(`
+SELECT id, summary, secs_spent, active, created_at, updated_at
+FROM task
+WHERE id=?;
+ `, id)
+
+ if row.Err() != nil {
+ return task, row.Err()
+ }
+ err := row.Scan(&task.ID,
+ &task.Summary,
+ &task.SecsSpent,
+ &task.Active,
+ &task.CreatedAt,
+ &task.UpdatedAt,
+ )
+ if err != nil {
+ return task, err
+ }
+ task.CreatedAt = task.CreatedAt.Local()
+ task.UpdatedAt = task.UpdatedAt.Local()
+
+ return task, nil
+}
+
+func fetchTaskLogByID(db *sql.DB, id int) (types.TaskLogEntry, error) {
+ var tl types.TaskLogEntry
+ row := db.QueryRow(`
+SELECT id, task_id, begin_ts, end_ts, secs_spent, comment
+FROM task_log
+WHERE id=?;
+ `, id)
+
+ if row.Err() != nil {
+ return tl, row.Err()
+ }
+ err := row.Scan(&tl.ID,
+ &tl.TaskID,
+ &tl.BeginTS,
+ &tl.EndTS,
+ &tl.SecsSpent,
+ &tl.Comment,
+ )
+ if err != nil {
+ return tl, err
+ }
+ tl.BeginTS = tl.BeginTS.Local()
+ tl.EndTS = tl.EndTS.Local()
+
+ return tl, nil
+}
diff --git a/internal/persistence/queries_test.go b/internal/persistence/queries_test.go
new file mode 100644
index 0000000..88fa033
--- /dev/null
+++ b/internal/persistence/queries_test.go
@@ -0,0 +1,361 @@
+package persistence
+
+import (
+ "database/sql"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/dhth/hours/internal/types"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ _ "modernc.org/sqlite" // sqlite driver
+)
+
+const (
+ secsInOneHour = 60 * 60
+ taskLogComment = "a task log outside the time range"
+)
+
+func TestRepository(t *testing.T) {
+ testDB, err := sql.Open("sqlite", ":memory:")
+ require.NoErrorf(t, err, "error opening DB: %v", err)
+
+ err = InitDB(testDB)
+ require.NoErrorf(t, err, "error initializing DB: %v", err)
+
+ err = UpgradeDB(testDB, 1)
+ require.NoErrorf(t, err, "error upgrading DB: %v", err)
+
+ t.Run("TestInsertTask", func(t *testing.T) {
+ t.Cleanup(func() { cleanupDB(t, testDB) })
+
+ // GIVEN
+ referenceTS := time.Now()
+ seedData := getTestData(referenceTS)
+ seedDB(t, testDB, seedData)
+
+ // WHEN
+ summary := "task 1"
+ taskID, err := InsertTask(testDB, summary)
+
+ // THEN
+ require.NoError(t, err, "failed to insert task")
+
+ task, fetchErr := fetchTaskByID(testDB, taskID)
+ require.NoError(t, fetchErr, "failed to fetch task")
+
+ assert.Equal(t, 3, task.ID)
+ assert.Equal(t, summary, task.Summary)
+ assert.True(t, task.Active)
+ assert.Zero(t, task.SecsSpent)
+ })
+
+ t.Run("TestUpdateActiveTL", func(t *testing.T) {
+ t.Cleanup(func() { cleanupDB(t, testDB) })
+
+ // GIVEN
+ referenceTS := time.Now()
+ seedData := getTestData(referenceTS)
+ seedDB(t, testDB, seedData)
+ taskID := 1
+ numSeconds := 60 * 90
+ endTS := time.Now()
+ beginTS := endTS.Add(time.Second * -1 * time.Duration(numSeconds))
+ tlID, insertErr := InsertNewTL(testDB, taskID, beginTS)
+ require.NoError(t, insertErr, "failed to insert task log")
+
+ taskBefore, err := fetchTaskByID(testDB, taskID)
+ require.NoError(t, err, "failed to fetch task")
+ numSecondsBefore := taskBefore.SecsSpent
+
+ // WHEN
+ comment := "a task log"
+ err = UpdateActiveTL(testDB, tlID, taskID, beginTS, endTS, numSeconds, comment)
+
+ // THEN
+ require.NoError(t, err, "failed to update task log")
+
+ taskLog, err := fetchTaskLogByID(testDB, tlID)
+ require.NoError(t, err, "failed to fetch task log")
+
+ taskAfter, err := fetchTaskByID(testDB, taskID)
+ require.NoError(t, err, "failed to fetch task")
+
+ assert.Equal(t, numSeconds, taskLog.SecsSpent)
+ assert.Equal(t, comment, taskLog.Comment)
+ assert.Equal(t, numSecondsBefore+numSeconds, taskAfter.SecsSpent)
+ })
+
+ t.Run("TestInsertManualTL", func(t *testing.T) {
+ t.Cleanup(func() { cleanupDB(t, testDB) })
+
+ // GIVEN
+ referenceTS := time.Now()
+ seedData := getTestData(referenceTS)
+ seedDB(t, testDB, seedData)
+ taskID := 1
+
+ taskBefore, err := fetchTaskByID(testDB, taskID)
+ require.NoError(t, err, "failed to fetch task")
+ numSecondsBefore := taskBefore.SecsSpent
+
+ // WHEN
+ comment := "a task log"
+ numSeconds := 60 * 90
+ endTS := time.Now()
+ beginTS := endTS.Add(time.Second * -1 * time.Duration(numSeconds))
+ tlID, err := InsertManualTL(testDB, taskID, beginTS, endTS, comment)
+
+ // THEN
+ require.NoError(t, err, "failed to insert task log")
+
+ taskLog, err := fetchTaskLogByID(testDB, tlID)
+ require.NoError(t, err, "failed to fetch task log")
+
+ taskAfter, err := fetchTaskByID(testDB, taskID)
+ require.NoError(t, err, "failed to fetch task")
+
+ assert.Equal(t, numSeconds, taskLog.SecsSpent)
+ assert.Equal(t, comment, taskLog.Comment)
+ assert.Equal(t, numSecondsBefore+numSeconds, taskAfter.SecsSpent)
+ })
+
+ t.Run("TestDeleteTaskLogEntry", func(t *testing.T) {
+ t.Cleanup(func() { cleanupDB(t, testDB) })
+
+ // GIVEN
+ referenceTS := time.Now()
+ seedData := getTestData(referenceTS)
+ seedDB(t, testDB, seedData)
+ taskID := 1
+ tlID := 1
+ taskBefore, err := fetchTaskByID(testDB, taskID)
+ require.NoError(t, err, "failed to fetch task")
+ numSecondsBefore := taskBefore.SecsSpent
+ taskLog, err := fetchTaskLogByID(testDB, tlID)
+ require.NoError(t, err, "failed to fetch task log")
+
+ // WHEN
+ err = DeleteTaskLogEntry(testDB, &taskLog)
+
+ // THEN
+ require.NoError(t, err, "failed to insert task log")
+
+ taskAfter, err := fetchTaskByID(testDB, taskID)
+ require.NoError(t, err, "failed to fetch task")
+
+ assert.Equal(t, numSecondsBefore-taskLog.SecsSpent, taskAfter.SecsSpent)
+ })
+
+ t.Run("TestFetchTLEntriesBetweenTS", func(t *testing.T) {
+ t.Cleanup(func() { cleanupDB(t, testDB) })
+
+ // GIVEN
+ referenceTS := time.Date(2024, time.September, 1, 9, 0, 0, 0, time.Local)
+ seedData := getTestData(referenceTS)
+ seedDB(t, testDB, seedData)
+
+ taskID := 1
+ numSeconds := 60 * 90
+ tlEndTS := referenceTS.Add(time.Hour * 2)
+ tlBeginTS := tlEndTS.Add(time.Second * -1 * time.Duration(numSeconds))
+ _, err = InsertManualTL(testDB, taskID, tlBeginTS, tlEndTS, taskLogComment)
+ require.NoError(t, err, "failed to insert task log")
+
+ // WHEN
+ reportBeginTS := referenceTS.Add(time.Hour * 24 * 7 * -2)
+ entries, err := FetchTLEntriesBetweenTS(testDB, reportBeginTS, referenceTS, 100)
+
+ // THEN
+ require.NoError(t, err, "failed to fetch report entries")
+ require.Len(t, entries, 3)
+ })
+
+ t.Run("TestFetchStats", func(t *testing.T) {
+ t.Cleanup(func() { cleanupDB(t, testDB) })
+
+ // GIVEN
+ referenceTS := time.Date(2024, time.September, 1, 9, 0, 0, 0, time.Local)
+ seedData := getTestData(referenceTS)
+ seedDB(t, testDB, seedData)
+
+ taskID := 1
+ comment := "an extra task log"
+ numSeconds := 60 * 90
+ tlEndTS := referenceTS.Add(time.Hour * 2)
+ tlBeginTS := tlEndTS.Add(time.Second * -1 * time.Duration(numSeconds))
+ _, err = InsertManualTL(testDB, taskID, tlBeginTS, tlEndTS, comment)
+ require.NoError(t, err, "failed to insert task log")
+
+ // WHEN
+ entries, err := FetchStats(testDB, 100)
+
+ // THEN
+ require.NoError(t, err, "failed to fetch report entries")
+ require.Len(t, entries, 2)
+
+ assert.Equal(t, 1, entries[0].TaskID)
+ assert.Equal(t, 3, entries[0].NumEntries)
+ assert.Equal(t, 5*secsInOneHour+numSeconds, entries[0].SecsSpent)
+
+ assert.Equal(t, 2, entries[1].TaskID)
+ assert.Equal(t, 1, entries[1].NumEntries)
+ assert.Equal(t, 4*secsInOneHour, entries[1].SecsSpent)
+ })
+
+ t.Run("TestFetchStatsBetweenTS", func(t *testing.T) {
+ t.Cleanup(func() { cleanupDB(t, testDB) })
+
+ // GIVEN
+ referenceTS := time.Date(2024, time.September, 1, 9, 0, 0, 0, time.Local)
+ seedData := getTestData(referenceTS)
+ seedDB(t, testDB, seedData)
+
+ taskID := 1
+ numSeconds := 60 * 90
+ tlEndTS := referenceTS.Add(time.Hour * 2)
+ tlBeginTS := tlEndTS.Add(time.Second * -1 * time.Duration(numSeconds))
+ _, err = InsertManualTL(testDB, taskID, tlBeginTS, tlEndTS, taskLogComment)
+ require.NoError(t, err, "failed to insert task log")
+
+ // WHEN
+ reportBeginTS := referenceTS.Add(time.Hour * 24 * 7 * -2)
+ entries, err := FetchStatsBetweenTS(testDB, reportBeginTS, referenceTS, 100)
+
+ // THEN
+ require.NoError(t, err, "failed to fetch report entries")
+ require.Len(t, entries, 2)
+
+ assert.Equal(t, 1, entries[0].TaskID)
+ assert.Equal(t, 2, entries[0].NumEntries)
+ assert.Equal(t, 5*secsInOneHour, entries[0].SecsSpent)
+
+ assert.Equal(t, 2, entries[1].TaskID)
+ assert.Equal(t, 1, entries[1].NumEntries)
+ assert.Equal(t, 4*secsInOneHour, entries[1].SecsSpent)
+ })
+
+ t.Run("TestFetchReportBetweenTS", func(t *testing.T) {
+ t.Cleanup(func() { cleanupDB(t, testDB) })
+
+ // GIVEN
+ referenceTS := time.Date(2024, time.September, 1, 9, 0, 0, 0, time.Local)
+ seedData := getTestData(referenceTS)
+ seedDB(t, testDB, seedData)
+
+ taskID := 1
+ numSeconds := 60 * 90
+ tlEndTS := referenceTS.Add(time.Hour * 2)
+ tlBeginTS := tlEndTS.Add(time.Second * -1 * time.Duration(numSeconds))
+ _, err = InsertManualTL(testDB, taskID, tlBeginTS, tlEndTS, taskLogComment)
+ require.NoError(t, err, "failed to insert task log")
+
+ // WHEN
+ reportBeginTS := referenceTS.Add(time.Hour * 24 * 7 * -2)
+ entries, err := FetchReportBetweenTS(testDB, reportBeginTS, referenceTS, 100)
+
+ // THEN
+ require.NoError(t, err, "failed to fetch report entries")
+
+ require.Len(t, entries, 2)
+ assert.Equal(t, 2, entries[0].TaskID)
+ assert.Equal(t, 1, entries[0].NumEntries)
+ assert.Equal(t, 4*secsInOneHour, entries[0].SecsSpent)
+
+ assert.Equal(t, 1, entries[1].TaskID)
+ assert.Equal(t, 2, entries[1].NumEntries)
+ assert.Equal(t, 5*secsInOneHour, entries[1].SecsSpent)
+ })
+
+ err = testDB.Close()
+ require.NoErrorf(t, err, "error closing DB: %v", err)
+}
+
+func cleanupDB(t *testing.T, testDB *sql.DB) {
+ t.Helper()
+
+ var err error
+ for _, tbl := range []string{"task_log", "task"} {
+ _, err = testDB.Exec(fmt.Sprintf("DELETE FROM %s", tbl))
+ require.NoErrorf(t, err, "failed to clean up table %q: %v", tbl, err)
+
+ _, err := testDB.Exec("DELETE FROM sqlite_sequence WHERE name=?;", tbl)
+ require.NoErrorf(t, err, "failed to reset auto increment for table %q: %v", tbl, err)
+ }
+}
+
+type testData struct {
+ tasks []types.Task
+ taskLogs []types.TaskLogEntry
+}
+
+func getTestData(referenceTS time.Time) testData {
+ ua := referenceTS.UTC()
+ ca := ua.Add(time.Hour * 24 * 7 * -1)
+ tasks := []types.Task{
+ {
+ ID: 1,
+ Summary: "seeded task 1",
+ Active: true,
+ CreatedAt: ca,
+ UpdatedAt: ca.Add(time.Hour * 9),
+ SecsSpent: 5 * secsInOneHour,
+ },
+ {
+ ID: 2,
+ Summary: "seeded task 2",
+ Active: true,
+ CreatedAt: ca,
+ UpdatedAt: ca.Add(time.Hour * 6),
+ SecsSpent: 4 * secsInOneHour,
+ },
+ }
+
+ taskLogs := []types.TaskLogEntry{
+ {
+ ID: 1,
+ TaskID: 1,
+ BeginTS: ca.Add(time.Hour * 2),
+ EndTS: ca.Add(time.Hour * 4),
+ SecsSpent: 2 * secsInOneHour,
+ Comment: "task 1 tl 1",
+ },
+ {
+ ID: 2,
+ TaskID: 1,
+ BeginTS: ca.Add(time.Hour * 6),
+ EndTS: ca.Add(time.Hour * 9),
+ SecsSpent: 3 * secsInOneHour,
+ Comment: "task 1 tl 2",
+ },
+ {
+ ID: 3,
+ TaskID: 2,
+ BeginTS: ca.Add(time.Hour * 2),
+ EndTS: ca.Add(time.Hour * 6),
+ SecsSpent: 4 * secsInOneHour,
+ Comment: "task 2 tl 1",
+ },
+ }
+
+ return testData{tasks, taskLogs}
+}
+
+func seedDB(t *testing.T, db *sql.DB, data testData) {
+ t.Helper()
+
+ for _, task := range data.tasks {
+ _, err := db.Exec(`
+INSERT INTO task (id, summary, secs_spent, active, created_at, updated_at)
+VALUES (?, ?, ?, ?, ?, ?)`, task.ID, task.Summary, task.SecsSpent, task.Active, task.CreatedAt, task.UpdatedAt)
+ require.NoError(t, err, "failed to insert data into table \"task\": %v", err)
+ }
+
+ for _, taskLog := range data.taskLogs {
+ _, err := db.Exec(`
+INSERT INTO task_log (id, task_id, begin_ts, end_ts, secs_spent, comment, active)
+VALUES (?, ?, ?, ?, ?, ?, ?)`, taskLog.ID, taskLog.TaskID, taskLog.BeginTS, taskLog.EndTS, taskLog.SecsSpent, taskLog.Comment, false)
+ require.NoError(t, err, "failed to insert data into table \"task_log\": %v", err)
+ }
+}
diff --git a/internal/ui/date_helpers.go b/internal/types/date_helpers.go
similarity index 54%
rename from internal/ui/date_helpers.go
rename to internal/types/date_helpers.go
index 62abf24..ee6293e 100644
--- a/internal/ui/date_helpers.go
+++ b/internal/types/date_helpers.go
@@ -1,63 +1,74 @@
-package ui
+package types
import (
+ "errors"
"fmt"
"strings"
"time"
)
const (
timePeriodDaysUpperBound = 7
+ TimePeriodWeek = "week"
+ timeFormat = "2006/01/02 15:04"
+ timeOnlyFormat = "15:04"
+ dayFormat = "Monday"
+ friendlyTimeFormat = "Mon, 15:04"
+ dateFormat = "2006/01/02"
)
var (
- 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)
+ errDateRangeIncorrect = errors.New("date range is incorrect")
+ errStartDateIncorrect = errors.New("start date is incorrect")
+ errEndDateIncorrect = errors.New("end date is incorrect")
+ errEndDateIsNotAfterStartDate = errors.New("end date is not after start date")
+ errTimePeriodNotValid = errors.New("time period is not valid")
+ errTimePeriodTooLarge = errors.New("time period is too large")
)
-type timePeriod struct {
- start time.Time
- end time.Time
- numDays int
+type TimePeriod struct {
+ Start time.Time
+ End time.Time
+ NumDays int
}
-func parseDateDuration(dateRange string) (timePeriod, bool) {
- var tp timePeriod
+func parseDateDuration(dateRange string) (TimePeriod, error) {
+ var tp TimePeriod
elements := strings.Split(dateRange, "...")
if len(elements) != 2 {
- return tp, false
+ return tp, fmt.Errorf("%w: date range needs to be of the format: %s...%s", errDateRangeIncorrect, dateFormat, dateFormat)
}
start, err := time.ParseInLocation(string(dateFormat), elements[0], time.Local)
if err != nil {
- return tp, false
+ return tp, fmt.Errorf("%w: %s", errStartDateIncorrect, err.Error())
}
end, err := time.ParseInLocation(string(dateFormat), elements[1], time.Local)
if err != nil {
- return tp, false
+ return tp, fmt.Errorf("%w: %s", errEndDateIncorrect, err.Error())
}
if end.Sub(start) <= 0 {
- return tp, false
+ return tp, fmt.Errorf("%w", errEndDateIsNotAfterStartDate)
}
- tp.start = start
- tp.end = end
- tp.numDays = int(end.Sub(start).Hours()/24) + 1
+ tp.Start = start
+ tp.End = end
+ tp.NumDays = int(end.Sub(start).Hours()/24) + 1
- return tp, true
+ return tp, nil
}
-func getTimePeriod(period string, now time.Time, fullWeek bool) (timePeriod, error) {
+func GetTimePeriod(period string, now time.Time, fullWeek bool) (TimePeriod, error) {
var start, end time.Time
var numDays int
switch period {
case "today":
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 0, 1)
numDays = 1
@@ -68,94 +79,79 @@ func getTimePeriod(period string, now time.Time, fullWeek bool) (timePeriod, err
end = start.AddDate(0, 0, 1)
numDays = 1
case "3d":
threeDaysBefore := now.AddDate(0, 0, -2)
start = time.Date(threeDaysBefore.Year(), threeDaysBefore.Month(), threeDaysBefore.Day(), 0, 0, 0, 0, threeDaysBefore.Location())
end = start.AddDate(0, 0, 3)
numDays = 3
- case "week":
+ case TimePeriodWeek:
weekday := now.Weekday()
offset := (7 + weekday - time.Monday) % 7
startOfWeek := now.AddDate(0, 0, -int(offset))
start = time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location())
if fullWeek {
numDays = 7
} else {
numDays = int(offset) + 1
}
end = start.AddDate(0, 0, numDays)
default:
var err error
if strings.Contains(period, "...") {
- var ts timePeriod
- var ok bool
- ts, ok = parseDateDuration(period)
- if !ok {
- return ts, timePeriodNotValidErr
- }
- if ts.numDays > timePeriodDaysUpperBound {
- return ts, timePeriodTooLargeErr
+ var ts TimePeriod
+ ts, err = parseDateDuration(period)
+ if err != nil {
+ return ts, fmt.Errorf("%w: %s", errTimePeriodNotValid, err.Error())
}
- start = ts.start
- end = ts.end.AddDate(0, 0, 1)
- numDays = ts.numDays
+ if ts.NumDays > timePeriodDaysUpperBound {
+ return ts, fmt.Errorf("%w: maximum number of days allowed (both inclusive): %d", errTimePeriodTooLarge, timePeriodDaysUpperBound)
+ }
+
+ start = ts.Start
+ end = ts.End.AddDate(0, 0, 1)
+ numDays = ts.NumDays
} else {
start, err = time.ParseInLocation(string(dateFormat), period, time.Local)
if err != nil {
- return timePeriod{}, timePeriodNotValidErr
+ return TimePeriod{}, fmt.Errorf("%w: %s", errTimePeriodNotValid, err.Error())
}
end = start.AddDate(0, 0, 1)
numDays = 1
}
}
- return timePeriod{
- start: start,
- end: end,
- numDays: numDays,
+ return TimePeriod{
+ Start: start,
+ End: end,
+ NumDays: numDays,
}, nil
}
-type timeShiftDirection uint8
-
-const (
- shiftForward timeShiftDirection = iota
- shiftBackward
-)
-
-type timeShiftDuration uint8
-
-const (
- shiftMinute timeShiftDuration = iota
- shiftFiveMinutes
- shiftHour
-)
-
-func getShiftedTime(ts time.Time, direction timeShiftDirection, duration timeShiftDuration) time.Time {
+func GetShiftedTime(ts time.Time, direction TimeShiftDirection, duration TimeShiftDuration) time.Time {
var d time.Duration
switch duration {
- case shiftMinute:
+ case ShiftMinute:
d = time.Minute
- case shiftFiveMinutes:
+ case ShiftFiveMinutes:
d = time.Minute * 5
- case shiftHour:
+ case ShiftHour:
d = time.Hour
}
- if direction == shiftBackward {
+ if direction == ShiftBackward {
d = -1 * d
}
return ts.Add(d)
}
type tsRelative uint8
const (
tsFromFuture tsRelative = iota
tsFromToday
diff --git a/internal/ui/date_helpers_test.go b/internal/types/date_helpers_test.go
similarity index 84%
rename from internal/ui/date_helpers_test.go
rename to internal/types/date_helpers_test.go
index df3fbd7..8cd1bc3 100644
--- a/internal/ui/date_helpers_test.go
+++ b/internal/types/date_helpers_test.go
@@ -1,95 +1,100 @@
-package ui
+package types
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestParseDateDuration(t *testing.T) {
testCases := []struct {
name string
input string
expectedStartStr string
expectedEndStr string
expectedNumDays int
- ok bool
+ 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",
expectedNumDays: 2,
- ok: true,
},
{
name: "a range of 2 days",
input: "2024/06/29...2024/07/01",
expectedStartStr: "2024/06/29 00:00",
expectedEndStr: "2024/07/01 00:00",
expectedNumDays: 3,
- ok: true,
},
{
name: "a range of 1 year",
input: "2024/06/29...2025/06/29",
expectedStartStr: "2024/06/29 00:00",
expectedEndStr: "2025/06/29 00:00",
expectedNumDays: 366,
- ok: true,
},
// failures
{
name: "empty string",
input: "",
+ err: errDateRangeIncorrect,
},
{
name: "only one date",
input: "2024/06/10",
+ err: errDateRangeIncorrect,
},
{
name: "badly formatted start date",
input: "2024/0610...2024/06/10",
+ err: errStartDateIncorrect,
},
{
name: "badly formatted end date",
input: "2024/06/10...2024/0610",
+ err: errEndDateIncorrect,
},
{
name: "a range of 0 days",
input: "2024/06/10...2024/06/10",
+ err: errEndDateIsNotAfterStartDate,
},
{
name: "end date before start date",
input: "2024/06/10...2024/06/08",
+ err: errEndDateIsNotAfterStartDate,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
- got, ok := parseDateDuration(tt.input)
+ got, err := parseDateDuration(tt.input)
- if tt.ok {
- startStr := got.start.Format(timeFormat)
- endStr := got.end.Format(timeFormat)
-
- assert.True(t, ok)
- assert.Equal(t, tt.expectedStartStr, startStr)
- assert.Equal(t, tt.expectedEndStr, endStr)
- assert.Equal(t, tt.expectedNumDays, got.numDays)
- } else {
- assert.False(t, ok)
+ if tt.err != nil {
+ assert.ErrorIs(t, err, tt.err)
+ return
}
+
+ startStr := got.Start.Format(timeFormat)
+ endStr := got.End.Format(timeFormat)
+
+ require.NoError(t, err)
+ assert.Equal(t, tt.expectedStartStr, startStr)
+ assert.Equal(t, tt.expectedEndStr, endStr)
+ assert.Equal(t, tt.expectedNumDays, got.NumDays)
})
}
}
func TestGetTimePeriod(t *testing.T) {
now, err := time.ParseInLocation(string(timeFormat), "2024/06/20 20:00", time.Local)
if err != nil {
t.Fatalf("error setting up the test: time is not valid: %s", err)
}
@@ -174,49 +179,49 @@ func TestGetTimePeriod(t *testing.T) {
name: "a date range",
period: "2024/06/15...2024/06/20",
expectedStartStr: "2024/06/15 00:00",
expectedEndStr: "2024/06/21 00:00",
expectedNumDays: 6,
},
// failures
{
name: "a faulty date",
period: "2024/06-15",
- err: timePeriodNotValidErr,
+ err: errTimePeriodNotValid,
},
{
name: "a faulty date range",
period: "2024/06/15...2024",
- err: timePeriodNotValidErr,
+ err: errTimePeriodNotValid,
},
{
name: "a date range too large",
period: "2024/06/15...2024/06/22",
- err: timePeriodTooLargeErr,
+ err: errTimePeriodTooLarge,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
- got, err := getTimePeriod(tt.period, tt.now, tt.fullWeek)
+ got, err := GetTimePeriod(tt.period, tt.now, tt.fullWeek)
- startStr := got.start.Format(timeFormat)
- endStr := got.end.Format(timeFormat)
+ 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.Equal(t, tt.expectedNumDays, got.numDays)
- assert.Nil(t, err)
- } else {
- assert.Equal(t, tt.err, err)
+ assert.Equal(t, tt.expectedNumDays, got.NumDays)
+ assert.NoError(t, err)
+ return
}
+ assert.ErrorIs(t, err, tt.err)
})
}
}
func TestGetTSRelative(t *testing.T) {
reference := time.Date(2024, 6, 29, 12, 0, 0, 0, time.Local)
testCases := []struct {
name string
ts time.Time
reference time.Time
diff --git a/internal/types/types.go b/internal/types/types.go
new file mode 100644
index 0000000..b34a7ad
--- /dev/null
+++ b/internal/types/types.go
@@ -0,0 +1,158 @@
+package types
+
+import (
+ "fmt"
+ "math"
+ "time"
+
+ "github.com/dhth/hours/internal/utils"
+ "github.com/dustin/go-humanize"
+)
+
+type Task struct {
+ ID int
+ Summary string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ TrackingActive bool
+ SecsSpent int
+ Active bool
+ TaskTitle string
+ TaskDesc string
+}
+
+type TaskLogEntry struct {
+ ID int
+ TaskID int
+ TaskSummary string
+ BeginTS time.Time
+ EndTS time.Time
+ SecsSpent int
+ Comment string
+ TLTitle string
+ TLDesc string
+}
+
+type ActiveTaskDetails struct {
+ TaskID int
+ TaskSummary string
+ LastLogEntryBeginTS time.Time
+}
+
+type TaskReportEntry struct {
+ TaskID int
+ TaskSummary string
+ NumEntries int
+ SecsSpent int
+}
+
+func (t *Task) UpdateTitle() {
+ var trackingIndicator string
+ if t.TrackingActive {
+ trackingIndicator = "⏲ "
+ }
+
+ t.TaskTitle = trackingIndicator + t.Summary
+}
+
+func (t *Task) UpdateDesc() {
+ var timeSpent string
+
+ if t.SecsSpent != 0 {
+ timeSpent = "worked on for " + HumanizeDuration(t.SecsSpent)
+ } else {
+ timeSpent = "no time spent"
+ }
+ lastUpdated := fmt.Sprintf("last updated: %s", humanize.Time(t.UpdatedAt))
+
+ t.TaskDesc = fmt.Sprintf("%s %s", utils.RightPadTrim(lastUpdated, 60, true), timeSpent)
+}
+
+func (tl *TaskLogEntry) UpdateTitle() {
+ tl.TLTitle = utils.Trim(tl.Comment, 60)
+}
+
+func (tl *TaskLogEntry) UpdateDesc() {
+ timeSpentStr := HumanizeDuration(tl.SecsSpent)
+
+ var timeStr string
+ var durationMsg string
+
+ endTSRelative := getTSRelative(tl.EndTS, time.Now())
+
+ switch endTSRelative {
+ case tsFromToday:
+ durationMsg = fmt.Sprintf("%s ... %s", tl.BeginTS.Format(timeOnlyFormat), tl.EndTS.Format(timeOnlyFormat))
+ case tsFromYesterday:
+ durationMsg = "Yesterday"
+ case tsFromThisWeek:
+ durationMsg = tl.EndTS.Format(dayFormat)
+ default:
+ durationMsg = humanize.Time(tl.EndTS)
+ }
+
+ timeStr = fmt.Sprintf("%s (%s)",
+ utils.RightPadTrim(durationMsg, 40, true),
+ timeSpentStr)
+
+ tl.TLDesc = fmt.Sprintf("%s %s", utils.RightPadTrim("["+tl.TaskSummary+"]", 60, true), timeStr)
+}
+
+func (t Task) Title() string {
+ return t.TaskTitle
+}
+
+func (t Task) Description() string {
+ return t.TaskDesc
+}
+
+func (t Task) FilterValue() string {
+ return t.Summary
+}
+
+func (tl TaskLogEntry) Title() string {
+ return tl.TLTitle
+}
+
+func (tl TaskLogEntry) Description() string {
+ return tl.TLDesc
+}
+
+func (tl TaskLogEntry) FilterValue() string {
+ return tl.Comment
+}
+
+func HumanizeDuration(durationInSecs int) string {
+ duration := time.Duration(durationInSecs) * time.Second
+
+ if duration.Seconds() < 60 {
+ return fmt.Sprintf("%ds", int(duration.Seconds()))
+ }
+
+ if duration.Minutes() < 60 {
+ return fmt.Sprintf("%dm", int(duration.Minutes()))
+ }
+
+ modMins := int(math.Mod(duration.Minutes(), 60))
+
+ if modMins == 0 {
+ return fmt.Sprintf("%dh", int(duration.Hours()))
+ }
+
+ return fmt.Sprintf("%dh %dm", int(duration.Hours()), modMins)
+}
+
+type TimeShiftDirection uint8
+
+const (
+ ShiftForward TimeShiftDirection = iota
+ ShiftBackward
+)
+
+type TimeShiftDuration uint8
+
+const (
+ ShiftMinute TimeShiftDuration = iota
+ ShiftFiveMinutes
+ ShiftHour
+)
diff --git a/internal/types/types_test.go b/internal/types/types_test.go
new file mode 100644
index 0000000..8fc09b3
--- /dev/null
+++ b/internal/types/types_test.go
@@ -0,0 +1,58 @@
+package types
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHumanizeDuration(t *testing.T) {
+ testCases := []struct {
+ name string
+ input int
+ expected string
+ }{
+ {
+ name: "0 seconds",
+ input: 0,
+ expected: "0s",
+ },
+ {
+ name: "30 seconds",
+ input: 30,
+ expected: "30s",
+ },
+ {
+ name: "60 seconds",
+ input: 60,
+ expected: "1m",
+ },
+ {
+ name: "1805 seconds",
+ input: 1805,
+ expected: "30m",
+ },
+ {
+ name: "3605 seconds",
+ input: 3605,
+ expected: "1h",
+ },
+ {
+ name: "4200 seconds",
+ input: 4200,
+ expected: "1h 10m",
+ },
+ {
+ name: "87000 seconds",
+ input: 87000,
+ expected: "24h 10m",
+ },
+ }
+
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HumanizeDuration(tt.input)
+ assert.Equal(t, tt.expected, got)
+ })
+ }
+}
diff --git a/internal/ui/active.go b/internal/ui/active.go
index bb5a35d..5cf26f6 100644
--- a/internal/ui/active.go
+++ b/internal/ui/active.go
@@ -1,42 +1,43 @@
package ui
import (
"database/sql"
"fmt"
"io"
- "os"
"strings"
"time"
+
+ pers "github.com/dhth/hours/internal/persistence"
+ "github.com/dhth/hours/internal/types"
)
const (
ActiveTaskPlaceholder = "{{task}}"
ActiveTaskTimePlaceholder = "{{time}}"
activeSecsThreshold = 60
activeSecsThresholdStr = "<1m"
)
-func ShowActiveTask(db *sql.DB, writer io.Writer, template string) {
- activeTaskDetails, err := fetchActiveTaskFromDB(db)
+func ShowActiveTask(db *sql.DB, writer io.Writer, template string) error {
+ activeTaskDetails, err := pers.FetchActiveTask(db)
if err != nil {
- fmt.Fprintf(os.Stdout, "Something went wrong:\n%s", err)
- os.Exit(1)
+ return err
}
- if activeTaskDetails.taskId == -1 {
- return
+ if activeTaskDetails.TaskID == -1 {
+ return nil
}
- now := time.Now()
- timeSpent := now.Sub(activeTaskDetails.lastLogEntryBeginTs).Seconds()
+ timeSpent := time.Since(activeTaskDetails.LastLogEntryBeginTS).Seconds()
var timeSpentStr string
if timeSpent <= activeSecsThreshold {
timeSpentStr = activeSecsThresholdStr
} else {
- timeSpentStr = humanizeDuration(int(timeSpent))
+ timeSpentStr = types.HumanizeDuration(int(timeSpent))
}
- activeStr := strings.Replace(template, ActiveTaskPlaceholder, activeTaskDetails.taskSummary, 1)
+ activeStr := strings.Replace(template, ActiveTaskPlaceholder, activeTaskDetails.TaskSummary, 1)
activeStr = strings.Replace(activeStr, ActiveTaskTimePlaceholder, timeSpentStr, 1)
fmt.Fprint(writer, activeStr)
+ return nil
}
diff --git a/internal/ui/cmds.go b/internal/ui/cmds.go
index b4d7c1b..020d771 100644
--- a/internal/ui/cmds.go
+++ b/internal/ui/cmds.go
@@ -1,161 +1,164 @@
package ui
import (
"database/sql"
+ "errors"
"time"
tea "github.com/charmbracelet/bubbletea"
- _ "modernc.org/sqlite"
+ pers "github.com/dhth/hours/internal/persistence"
+ "github.com/dhth/hours/internal/types"
+ _ "modernc.org/sqlite" // sqlite driver
)
func toggleTracking(db *sql.DB,
- taskId int,
+ taskID int,
beginTs time.Time,
endTs time.Time,
comment string,
) tea.Cmd {
return func() tea.Msg {
row := db.QueryRow(`
SELECT id, task_id
FROM task_log
WHERE active=1
ORDER BY begin_ts DESC
LIMIT 1
`)
var trackStatus trackingStatus
- var activeTaskLogId int
- var activeTaskId int
+ var activeTaskLogID int
+ var activeTaskID int
- err := row.Scan(&activeTaskLogId, &activeTaskId)
- if err == sql.ErrNoRows {
+ err := row.Scan(&activeTaskLogID, &activeTaskID)
+ if errors.Is(err, sql.ErrNoRows) {
trackStatus = trackingInactive
} else if err != nil {
return trackingToggledMsg{err: err}
} else {
trackStatus = trackingActive
}
switch trackStatus {
case trackingInactive:
- err = insertNewTLInDB(db, taskId, beginTs)
+ _, err = pers.InsertNewTL(db, taskID, beginTs)
if err != nil {
return trackingToggledMsg{err: err}
} else {
- return trackingToggledMsg{taskId: taskId}
+ return trackingToggledMsg{taskID: taskID}
}
default:
secsSpent := int(endTs.Sub(beginTs).Seconds())
- err := updateActiveTLInDB(db, activeTaskLogId, activeTaskId, beginTs, endTs, secsSpent, comment)
+ err := pers.UpdateActiveTL(db, activeTaskLogID, activeTaskID, beginTs, endTs, secsSpent, comment)
if err != nil {
return trackingToggledMsg{err: err}
} else {
- return trackingToggledMsg{taskId: taskId, finished: true, secsSpent: secsSpent}
+ return trackingToggledMsg{taskID: taskID, finished: true, secsSpent: secsSpent}
}
}
}
}
func updateTLBeginTS(db *sql.DB, beginTS time.Time) tea.Cmd {
return func() tea.Msg {
- err := updateTLBeginTSInDB(db, beginTS)
+ err := pers.UpdateTLBeginTS(db, beginTS)
return tlBeginTSUpdatedMsg{beginTS, err}
}
}
-func insertManualEntry(db *sql.DB, taskId int, beginTS time.Time, endTS time.Time, comment string) tea.Cmd {
+func insertManualEntry(db *sql.DB, taskID int, beginTS time.Time, endTS time.Time, comment string) tea.Cmd {
return func() tea.Msg {
- err := insertManualTLInDB(db, taskId, beginTS, endTS, comment)
- return manualTaskLogInserted{taskId, err}
+ _, err := pers.InsertManualTL(db, taskID, beginTS, endTS, comment)
+ return manualTaskLogInserted{taskID, err}
}
}
func fetchActiveTask(db *sql.DB) tea.Cmd {
return func() tea.Msg {
- activeTaskDetails, err := fetchActiveTaskFromDB(db)
+ activeTaskDetails, err := pers.FetchActiveTask(db)
if err != nil {
return activeTaskFetchedMsg{err: err}
}
- if activeTaskDetails.taskId == -1 {
+ if activeTaskDetails.TaskID == -1 {
return activeTaskFetchedMsg{noneActive: true}
}
return activeTaskFetchedMsg{
- activeTaskId: activeTaskDetails.taskId,
- beginTs: activeTaskDetails.lastLogEntryBeginTs,
+ activeTaskID: activeTaskDetails.TaskID,
+ beginTs: activeTaskDetails.LastLogEntryBeginTS,
}
}
}
-func updateTaskRep(db *sql.DB, t *task) tea.Cmd {
+func updateTaskRep(db *sql.DB, t *types.Task) tea.Cmd {
return func() tea.Msg {
- err := updateTaskDataFromDB(db, t)
+ err := pers.UpdateTaskData(db, t)
return taskRepUpdatedMsg{
tsk: t,
err: err,
}
}
}
func fetchTaskLogEntries(db *sql.DB) tea.Cmd {
return func() tea.Msg {
- entries, err := fetchTLEntriesFromDB(db, true, 50)
+ entries, err := pers.FetchTLEntries(db, true, 50)
return taskLogEntriesFetchedMsg{
entries: entries,
err: err,
}
}
}
-func deleteLogEntry(db *sql.DB, entry *taskLogEntry) tea.Cmd {
+func deleteLogEntry(db *sql.DB, entry *types.TaskLogEntry) tea.Cmd {
return func() tea.Msg {
- err := deleteEntry(db, entry)
+ err := pers.DeleteTaskLogEntry(db, entry)
return taskLogEntryDeletedMsg{
entry: entry,
err: err,
}
}
}
func deleteActiveTaskLog(db *sql.DB) tea.Cmd {
return func() tea.Msg {
- err := deleteActiveTLInDB(db)
+ err := pers.DeleteActiveTL(db)
return activeTaskLogDeletedMsg{err}
}
}
func createTask(db *sql.DB, summary string) tea.Cmd {
return func() tea.Msg {
- err := insertTaskInDB(db, summary)
+ _, err := pers.InsertTask(db, summary)
return taskCreatedMsg{err}
}
}
-func updateTask(db *sql.DB, task *task, summary string) tea.Cmd {
+func updateTask(db *sql.DB, task *types.Task, summary string) tea.Cmd {
return func() tea.Msg {
- err := updateTaskInDB(db, task.id, summary)
+ err := pers.UpdateTask(db, task.ID, summary)
return taskUpdatedMsg{task, summary, err}
}
}
-func updateTaskActiveStatus(db *sql.DB, task *task, active bool) tea.Cmd {
+func updateTaskActiveStatus(db *sql.DB, task *types.Task, active bool) tea.Cmd {
return func() tea.Msg {
- err := updateTaskActiveStatusInDB(db, task.id, active)
+ err := pers.UpdateTaskActiveStatus(db, task.ID, active)
return taskActiveStatusUpdated{task, active, err}
}
}
func fetchTasks(db *sql.DB, active bool) tea.Cmd {
return func() tea.Msg {
- tasks, err := fetchTasksFromDB(db, active, 50)
+ tasks, err := pers.FetchTasks(db, active, 50)
return tasksFetched{tasks, active, err}
}
}
func hideHelp(interval time.Duration) tea.Cmd {
return tea.Tick(interval, func(time.Time) tea.Msg {
return HideHelpMsg{}
})
}
@@ -163,23 +166,23 @@ func getRecordsData(analyticsType recordsType, db *sql.DB, period string, start,
return func() tea.Msg {
var data string
var err error
switch analyticsType {
case reportRecords:
data, err = getReport(db, start, numDays, plain)
case reportAggRecords:
data, err = getReportAgg(db, start, numDays, plain)
case reportLogs:
- data, err = renderTaskLog(db, start, end, 20, plain)
+ data, err = getTaskLog(db, start, end, 20, plain)
case reportStats:
- data, err = renderStats(db, period, start, end, plain)
+ data, err = getStats(db, period, start, end, plain)
}
return recordsDataFetchedMsg{
start: start,
end: end,
report: data,
err: err,
}
}
}
diff --git a/internal/ui/generate.go b/internal/ui/generate.go
index 36957cb..8788289 100644
--- a/internal/ui/generate.go
+++ b/internal/ui/generate.go
@@ -1,17 +1,19 @@
package ui
import (
"database/sql"
"fmt"
"math/rand"
"time"
+
+ pers "github.com/dhth/hours/internal/persistence"
)
var (
tasks = []string{
".net",
"assembly",
"c",
"c#",
"c++",
"clojure",
@@ -85,31 +87,31 @@ var (
"report",
"script",
"workflow",
"log",
}
)
func GenerateData(db *sql.DB, numDays, numTasks uint8) error {
for i := uint8(0); i < numTasks; i++ {
summary := tasks[rand.Intn(len(tasks))]
- err := insertTaskInDB(db, summary)
+ _, err := pers.InsertTask(db, summary)
if err != nil {
return err
}
numLogs := int(numDays/2) + rand.Intn(int(numDays/2))
for j := 0; j < numLogs; j++ {
beginTs := randomTimestamp(int(numDays))
numMinutes := 30 + rand.Intn(60)
endTs := beginTs.Add(time.Minute * time.Duration(numMinutes))
comment := fmt.Sprintf("%s %s", verbs[rand.Intn(len(verbs))], nouns[rand.Intn(len(nouns))])
- err = insertManualTLInDB(db, int(i+1), beginTs, endTs, comment)
+ _, err = pers.InsertManualTL(db, int(i+1), beginTs, endTs, comment)
if err != nil {
return err
}
}
}
return nil
}
func randomTimestamp(numDays int) time.Time {
diff --git a/internal/ui/initial.go b/internal/ui/initial.go
index ab83b95..783a5a6 100644
--- a/internal/ui/initial.go
+++ b/internal/ui/initial.go
@@ -1,22 +1,23 @@
package ui
import (
"database/sql"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
+ "github.com/dhth/hours/internal/types"
)
-func InitialModel(db *sql.DB) model {
+func InitialModel(db *sql.DB) Model {
var activeTaskItems []list.Item
var inactiveTaskItems []list.Item
var tasklogListItems []list.Item
trackingInputs := make([]textinput.Model, 3)
trackingInputs[entryBeginTS] = textinput.New()
trackingInputs[entryBeginTS].Placeholder = "09:30"
trackingInputs[entryBeginTS].Focus()
trackingInputs[entryBeginTS].CharLimit = len(string(timeFormat))
trackingInputs[entryBeginTS].Width = 30
@@ -33,25 +34,25 @@ func InitialModel(db *sql.DB) model {
trackingInputs[entryComment].CharLimit = 255
trackingInputs[entryComment].Width = 80
taskInputs := make([]textinput.Model, 3)
taskInputs[summaryField] = textinput.New()
taskInputs[summaryField].Placeholder = "task summary goes here"
taskInputs[summaryField].Focus()
taskInputs[summaryField].CharLimit = 100
taskInputs[entryBeginTS].Width = 60
- m := model{
+ m := Model{
db: db,
activeTasksList: list.New(activeTaskItems, newItemDelegate(lipgloss.Color(activeTaskListColor)), listWidth, 0),
inactiveTasksList: list.New(inactiveTaskItems, newItemDelegate(lipgloss.Color(inactiveTaskListColor)), listWidth, 0),
- activeTaskMap: make(map[int]*task),
+ activeTaskMap: make(map[int]*types.Task),
activeTaskIndexMap: make(map[int]int),
taskLogList: list.New(tasklogListItems, newItemDelegate(lipgloss.Color(taskLogListColor)), listWidth, 0),
showHelpIndicator: true,
trackingInputs: trackingInputs,
taskInputs: taskInputs,
}
m.activeTasksList.Title = "Tasks"
m.activeTasksList.SetStatusBarItemName("task", "tasks")
m.activeTasksList.DisableQuitKeybindings()
m.activeTasksList.SetShowHelp(false)
diff --git a/internal/ui/log.go b/internal/ui/log.go
index d4fcb7a..50b4de9 100644
--- a/internal/ui/log.go
+++ b/internal/ui/log.go
@@ -1,113 +1,121 @@
package ui
import (
"bytes"
"database/sql"
+ "errors"
"fmt"
"io"
- "os"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ pers "github.com/dhth/hours/internal/persistence"
+ "github.com/dhth/hours/internal/types"
+ "github.com/dhth/hours/internal/utils"
"github.com/olekukonko/tablewriter"
)
const (
- logNumDaysUpperBound = 7
- logTimeCharsBudget = 6
+ logNumDaysUpperBound = 7
+ logTimeCharsBudget = 6
+ interactiveLogDayLimit = 1
)
-func RenderTaskLog(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool) {
+var (
+ errInteractiveModeNotApplicable = errors.New("interactive mode is not applicable")
+ errCouldntGenerateLogs = errors.New("couldn't generate logs")
+)
+
+func RenderTaskLog(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool) error {
if period == "" {
- return
+ return nil
}
- ts, err := getTimePeriod(period, time.Now(), false)
+ ts, err := types.GetTimePeriod(period, time.Now(), false)
if err != nil {
- fmt.Printf("error: %s\n", err)
- os.Exit(1)
+ return err
}
- if interactive && ts.numDays > 1 {
- fmt.Print("Interactive mode for logs is limited to a day; use non-interactive mode to see logs for a larger time period\n")
- os.Exit(1)
+ if interactive && ts.NumDays > interactiveLogDayLimit {
+ return fmt.Errorf("%w (limited to %d day); use non-interactive mode to see logs for a larger time period", errInteractiveModeNotApplicable, interactiveLogDayLimit)
}
- log, err := renderTaskLog(db, ts.start, ts.end, 100, plain)
+ log, err := getTaskLog(db, ts.Start, ts.End, 100, plain)
if err != nil {
- fmt.Printf("Something went wrong generating the log: %s\n", err)
+ return fmt.Errorf("%w: %s", errCouldntGenerateLogs, err.Error())
}
if interactive {
- p := tea.NewProgram(initialRecordsModel(reportLogs, db, ts.start, ts.end, plain, period, ts.numDays, log))
- if _, err := p.Run(); err != nil {
- fmt.Printf("Alas, there has been an error: %v", err)
- os.Exit(1)
+ p := tea.NewProgram(initialRecordsModel(reportLogs, db, ts.Start, ts.End, plain, period, ts.NumDays, log))
+ _, err := p.Run()
+ if err != nil {
+ return err
}
} else {
fmt.Fprint(writer, log)
}
+ return nil
}
-func renderTaskLog(db *sql.DB, start, end time.Time, limit int, plain bool) (string, error) {
- entries, err := fetchTLEntriesBetweenTSFromDB(db, start, end, limit)
+func getTaskLog(db *sql.DB, start, end time.Time, limit int, plain bool) (string, error) {
+ entries, err := pers.FetchTLEntriesBetweenTS(db, start, end, limit)
if err != nil {
return "", err
}
var numEntriesInTable int
if len(entries) == 0 {
numEntriesInTable = 1
} else {
numEntriesInTable = len(entries)
}
data := make([][]string, numEntriesInTable)
if len(entries) == 0 {
data[0] = []string{
- RightPadTrim("", 20, false),
- RightPadTrim("", 40, false),
- RightPadTrim("", 39, false),
- RightPadTrim("", logTimeCharsBudget, false),
+ utils.RightPadTrim("", 20, false),
+ utils.RightPadTrim("", 40, false),
+ utils.RightPadTrim("", 39, false),
+ utils.RightPadTrim("", logTimeCharsBudget, false),
}
}
var timeSpentStr string
rs := getReportStyles(plain)
styleCache := make(map[string]lipgloss.Style)
for i, entry := range entries {
- timeSpentStr = humanizeDuration(entry.secsSpent)
+ timeSpentStr = types.HumanizeDuration(entry.SecsSpent)
if plain {
data[i] = []string{
- RightPadTrim(entry.taskSummary, 20, false),
- RightPadTrim(entry.comment, 40, false),
- fmt.Sprintf("%s ... %s", entry.beginTs.Format(timeFormat), entry.endTs.Format(timeFormat)),
- RightPadTrim(timeSpentStr, logTimeCharsBudget, false),
+ utils.RightPadTrim(entry.TaskSummary, 20, false),
+ utils.RightPadTrim(entry.Comment, 40, false),
+ fmt.Sprintf("%s ... %s", entry.BeginTS.Format(timeFormat), entry.EndTS.Format(timeFormat)),
+ utils.RightPadTrim(timeSpentStr, logTimeCharsBudget, false),
}
} else {
- rowStyle, ok := styleCache[entry.taskSummary]
+ rowStyle, ok := styleCache[entry.TaskSummary]
if !ok {
- rowStyle = getDynamicStyle(entry.taskSummary)
- styleCache[entry.taskSummary] = rowStyle
+ rowStyle = getDynamicStyle(entry.TaskSummary)
+ styleCache[entry.TaskSummary] = rowStyle
}
data[i] = []string{
- rowStyle.Render(RightPadTrim(entry.taskSummary, 20, false)),
- rowStyle.Render(RightPadTrim(entry.comment, 40, false)),
- rowStyle.Render(fmt.Sprintf("%s ... %s", entry.beginTs.Format(timeFormat), entry.endTs.Format(timeFormat))),
- rowStyle.Render(RightPadTrim(timeSpentStr, logTimeCharsBudget, false)),
+ rowStyle.Render(utils.RightPadTrim(entry.TaskSummary, 20, false)),
+ rowStyle.Render(utils.RightPadTrim(entry.Comment, 40, false)),
+ rowStyle.Render(fmt.Sprintf("%s ... %s", entry.BeginTS.Format(timeFormat), entry.EndTS.Format(timeFormat))),
+ rowStyle.Render(utils.RightPadTrim(timeSpentStr, logTimeCharsBudget, false)),
}
}
}
b := bytes.Buffer{}
table := tablewriter.NewWriter(&b)
headerValues := []string{"Task", "Comment", "Duration", "TimeSpent"}
headers := make([]string, len(headerValues))
for i, h := range headerValues {
diff --git a/internal/ui/model.go b/internal/ui/model.go
index 2279da0..0516e15 100644
--- a/internal/ui/model.go
+++ b/internal/ui/model.go
@@ -1,20 +1,21 @@
package ui
import (
"database/sql"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/dhth/hours/internal/types"
)
type trackingStatus uint
const (
trackingInactive trackingStatus = iota
trackingActive
)
type dBChange uint
@@ -75,51 +76,51 @@ const (
)
const (
timeFormat = "2006/01/02 15:04"
timeOnlyFormat = "15:04"
dayFormat = "Monday"
friendlyTimeFormat = "Mon, 15:04"
dateFormat = "2006/01/02"
)
-type model struct {
+type Model struct {
activeView stateView
lastView stateView
db *sql.DB
activeTasksList list.Model
inactiveTasksList list.Model
- activeTaskMap map[int]*task
+ activeTaskMap map[int]*types.Task
activeTaskIndexMap map[int]int
activeTLBeginTS time.Time
activeTLEndTS time.Time
tasksFetched bool
taskLogList list.Model
trackingInputs []textinput.Model
trackingFocussedField timeTrackingFormField
taskInputs []textinput.Model
taskMgmtContext taskMgmtContext
taskInputFocussedField taskInputField
helpVP viewport.Model
helpVPReady bool
lastChange dBChange
changesLocked bool
- activeTaskId int
+ activeTaskID int
tasklogSaveType tasklogSaveType
message string
messages []string
showHelpIndicator bool
terminalHeight int
trackingActive bool
}
-func (m model) Init() tea.Cmd {
+func (m Model) Init() tea.Cmd {
return tea.Batch(
hideHelp(time.Minute*1),
fetchTasks(m.db, true),
fetchTaskLogEntries(m.db),
fetchTasks(m.db, false),
)
}
type recordsModel struct {
db *sql.DB
@@ -128,13 +129,13 @@ type recordsModel struct {
end time.Time
period string
numDays int
plain bool
report string
quitting bool
busy bool
err error
}
-func (m recordsModel) Init() tea.Cmd {
+func (recordsModel) Init() tea.Cmd {
return nil
}
diff --git a/internal/ui/msgs.go b/internal/ui/msgs.go
index 153d6d7..8d82e95 100644
--- a/internal/ui/msgs.go
+++ b/internal/ui/msgs.go
@@ -1,77 +1,81 @@
package ui
-import "time"
+import (
+ "time"
+
+ "github.com/dhth/hours/internal/types"
+)
type HideHelpMsg struct{}
type trackingToggledMsg struct {
- taskId int
+ taskID int
finished bool
secsSpent int
err error
}
type taskRepUpdatedMsg struct {
- tsk *task
+ tsk *types.Task
err error
}
type manualTaskLogInserted struct {
- taskId int
+ taskID int
err error
}
type tlBeginTSUpdatedMsg struct {
beginTS time.Time
err error
}
type activeTaskLogDeletedMsg struct {
err error
}
type activeTaskFetchedMsg struct {
- activeTaskId int
+ activeTaskID int
beginTs time.Time
noneActive bool
err error
}
type taskLogEntriesFetchedMsg struct {
- entries []taskLogEntry
+ entries []types.TaskLogEntry
err error
}
type taskCreatedMsg struct {
err error
}
type taskUpdatedMsg struct {
- tsk *task
+ tsk *types.Task
summary string
err error
}
type taskActiveStatusUpdated struct {
- tsk *task
+ tsk *types.Task
active bool
err error
}
type taskLogEntryDeletedMsg struct {
- entry *taskLogEntry
+ entry *types.TaskLogEntry
err error
}
type tasksFetched struct {
- tasks []task
+ tasks []types.Task
active bool
err error
}
type recordsDataFetchedMsg struct {
start time.Time
end time.Time
report string
err error
}
diff --git a/internal/ui/queries.go b/internal/ui/queries.go
deleted file mode 100644
index 49d5909..0000000
--- a/internal/ui/queries.go
+++ /dev/null
@@ -1,522 +0,0 @@
-package ui
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "time"
-)
-
-func insertNewTLInDB(db *sql.DB, taskId int, beginTs time.Time) error {
- stmt, err := db.Prepare(`
-INSERT INTO task_log (task_id, begin_ts, active)
-VALUES (?, ?, ?);
-`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- _, err = stmt.Exec(taskId, beginTs.UTC(), true)
- if err != nil {
- return err
- }
-
- return nil
-}
-
-func updateTLBeginTSInDB(db *sql.DB, beginTs time.Time) error {
- stmt, err := db.Prepare(`
-UPDATE task_log SET begin_ts=?
-WHERE active is true;
-`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- _, err = stmt.Exec(beginTs.UTC(), true)
- if err != nil {
- return err
- }
-
- return nil
-}
-
-func deleteActiveTLInDB(db *sql.DB) error {
- stmt, err := db.Prepare(`
-DELETE FROM task_log
-WHERE active=true;
-`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- _, err = stmt.Exec()
-
- return err
-}
-
-func updateActiveTLInDB(db *sql.DB, taskLogId int, taskId int, beginTs, endTs time.Time, secsSpent int, comment string) error {
- tx, err := db.Begin()
- if err != nil {
- return err
- }
- defer func() {
- _ = tx.Rollback()
- }()
-
- stmt, err := tx.Prepare(`
-UPDATE task_log
-SET active = 0,
- begin_ts = ?,
- end_ts = ?,
- secs_spent = ?,
- comment = ?
-WHERE id = ?
-AND active = 1;
-`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- _, err = stmt.Exec(beginTs.UTC(), endTs.UTC(), secsSpent, comment, taskLogId)
- if err != nil {
- return err
- }
-
- tStmt, err := tx.Prepare(`
-UPDATE task
-SET secs_spent = secs_spent+?,
- updated_at = ?
-WHERE id = ?;
- `)
- if err != nil {
- return err
- }
- defer tStmt.Close()
-
- _, err = tStmt.Exec(secsSpent, time.Now().UTC(), taskId)
- if err != nil {
- return err
- }
-
- err = tx.Commit()
- if err != nil {
- return err
- }
-
- return nil
-}
-
-func insertManualTLInDB(db *sql.DB, taskId int, beginTs time.Time, endTs time.Time, comment string) error {
- secsSpent := int(endTs.Sub(beginTs).Seconds())
- tx, err := db.Begin()
- if err != nil {
- return err
- }
- defer func() {
- _ = tx.Rollback()
- }()
-
- stmt, err := tx.Prepare(`
-INSERT INTO task_log (task_id, begin_ts, end_ts, secs_spent, comment, active)
-VALUES (?, ?, ?, ?, ?, ?);
-`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- _, err = stmt.Exec(taskId, beginTs.UTC(), endTs.UTC(), secsSpent, comment, false)
- if err != nil {
- return err
- }
-
- tStmt, err := tx.Prepare(`
-UPDATE task
-SET secs_spent = secs_spent+?,
- updated_at = ?
-WHERE id = ?;
- `)
- if err != nil {
- return err
- }
- defer tStmt.Close()
-
- _, err = tStmt.Exec(secsSpent, time.Now().UTC(), taskId)
- if err != nil {
- return err
- }
-
- err = tx.Commit()
- if err != nil {
- return err
- }
-
- return nil
-}
-
-func fetchActiveTaskFromDB(db *sql.DB) (activeTaskDetails, error) {
- row := db.QueryRow(`
-SELECT t.id, t.summary, tl.begin_ts
-FROM task_log tl left join task t on tl.task_id = t.id
-WHERE tl.active=true;
-`)
-
- var activeTaskDetails activeTaskDetails
- err := row.Scan(
- &activeTaskDetails.taskId,
- &activeTaskDetails.taskSummary,
- &activeTaskDetails.lastLogEntryBeginTs,
- )
- if errors.Is(err, sql.ErrNoRows) {
- activeTaskDetails.taskId = -1
- return activeTaskDetails, nil
- } else if err != nil {
- return activeTaskDetails, err
- }
- activeTaskDetails.lastLogEntryBeginTs = activeTaskDetails.lastLogEntryBeginTs.Local()
- return activeTaskDetails, nil
-}
-
-func insertTaskInDB(db *sql.DB, summary string) error {
- stmt, err := db.Prepare(`
-INSERT into task (summary, active, created_at, updated_at)
-VALUES (?, true, ?, ?);
-`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- now := time.Now().UTC()
- _, err = stmt.Exec(summary, now, now)
- if err != nil {
- return err
- }
- return nil
-}
-
-func updateTaskInDB(db *sql.DB, id int, summary string) error {
- stmt, err := db.Prepare(`
-UPDATE task
-SET summary = ?,
- updated_at = ?
-WHERE id = ?
-`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- _, err = stmt.Exec(summary, time.Now().UTC(), id)
- if err != nil {
- return err
- }
- return nil
-}
-
-func updateTaskActiveStatusInDB(db *sql.DB, id int, active bool) error {
- stmt, err := db.Prepare(`
-UPDATE task
-SET active = ?,
- updated_at = ?
-WHERE id = ?
-`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- _, err = stmt.Exec(active, time.Now().UTC(), id)
- if err != nil {
- return err
- }
- return nil
-}
-
-func updateTaskDataFromDB(db *sql.DB, t *task) error {
- row := db.QueryRow(`
-SELECT secs_spent, updated_at
-FROM task
-WHERE id=?;
- `, t.id)
-
- err := row.Scan(
- &t.secsSpent,
- &t.updatedAt,
- )
- if err != nil {
- return err
- }
- return nil
-}
-
-func fetchTasksFromDB(db *sql.DB, active bool, limit int) ([]task, error) {
- var tasks []task
-
- rows, err := db.Query(`
-SELECT id, summary, secs_spent, created_at, updated_at, active
-FROM task
-WHERE active=?
-ORDER by updated_at DESC
-LIMIT ?;
- `, active, limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- for rows.Next() {
- var entry task
- err = rows.Scan(&entry.id,
- &entry.summary,
- &entry.secsSpent,
- &entry.createdAt,
- &entry.updatedAt,
- &entry.active,
- )
- if err != nil {
- return nil, err
- }
- entry.createdAt = entry.createdAt.Local()
- entry.updatedAt = entry.updatedAt.Local()
- tasks = append(tasks, entry)
-
- }
- return tasks, nil
-}
-
-func fetchTLEntriesFromDB(db *sql.DB, desc bool, limit int) ([]taskLogEntry, error) {
- var logEntries []taskLogEntry
-
- var order string
- if desc {
- order = "DESC"
- } else {
- order = "ASC"
- }
- query := fmt.Sprintf(`
-SELECT tl.id, tl.task_id, t.summary, tl.begin_ts, tl.end_ts, tl.secs_spent, tl.comment
-FROM task_log tl left join task t on tl.task_id=t.id
-WHERE tl.active=false
-ORDER by tl.begin_ts %s
-LIMIT ?;
-`, order)
-
- rows, err := db.Query(query, limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- for rows.Next() {
- var entry taskLogEntry
- err = rows.Scan(&entry.id,
- &entry.taskId,
- &entry.taskSummary,
- &entry.beginTs,
- &entry.endTs,
- &entry.secsSpent,
- &entry.comment,
- )
- if err != nil {
- return nil, err
- }
- entry.beginTs = entry.beginTs.Local()
- entry.endTs = entry.endTs.Local()
- logEntries = append(logEntries, entry)
-
- }
- return logEntries, nil
-}
-
-func fetchTLEntriesBetweenTSFromDB(db *sql.DB, beginTs, endTs time.Time, limit int) ([]taskLogEntry, error) {
- var logEntries []taskLogEntry
-
- rows, err := db.Query(`
-SELECT tl.id, tl.task_id, t.summary, tl.begin_ts, tl.end_ts, tl.secs_spent, tl.comment
-FROM task_log tl left join task t on tl.task_id=t.id
-WHERE tl.active=false
-AND tl.end_ts >= ?
-AND tl.end_ts < ?
-ORDER by tl.begin_ts ASC LIMIT ?;
- `, beginTs.UTC(), endTs.UTC(), limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- for rows.Next() {
- var entry taskLogEntry
- err = rows.Scan(&entry.id,
- &entry.taskId,
- &entry.taskSummary,
- &entry.beginTs,
- &entry.endTs,
- &entry.secsSpent,
- &entry.comment,
- )
- if err != nil {
- return nil, err
- }
- entry.beginTs = entry.beginTs.Local()
- entry.endTs = entry.endTs.Local()
- logEntries = append(logEntries, entry)
-
- }
- return logEntries, nil
-}
-
-func fetchStatsFromDB(db *sql.DB, limit int) ([]taskReportEntry, error) {
- rows, err := db.Query(`
-SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries, t.secs_spent
-from task_log tl
-LEFT JOIN task t on tl.task_id = t.id
-GROUP BY tl.task_id
-ORDER BY t.secs_spent DESC
-limit ?;
-`, limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var tLE []taskReportEntry
-
- for rows.Next() {
- var entry taskReportEntry
- err = rows.Scan(
- &entry.taskId,
- &entry.taskSummary,
- &entry.numEntries,
- &entry.secsSpent,
- )
- if err != nil {
- return nil, err
- }
- tLE = append(tLE, entry)
-
- }
- return tLE, nil
-}
-
-func fetchStatsBetweenTSFromDB(db *sql.DB, beginTs, endTs time.Time, limit int) ([]taskReportEntry, error) {
- rows, err := db.Query(`
-SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries, SUM(tl.secs_spent) AS secs_spent
-FROM task_log tl
-LEFT JOIN task t ON tl.task_id = t.id
-WHERE tl.end_ts >= ? AND tl.end_ts < ?
-GROUP BY tl.task_id
-ORDER BY secs_spent DESC
-LIMIT ?;
-`, beginTs.UTC(), endTs.UTC(), limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var tLE []taskReportEntry
-
- for rows.Next() {
- var entry taskReportEntry
- err = rows.Scan(
- &entry.taskId,
- &entry.taskSummary,
- &entry.numEntries,
- &entry.secsSpent,
- )
- if err != nil {
- return nil, err
- }
- tLE = append(tLE, entry)
-
- }
- return tLE, nil
-}
-
-func fetchReportBetweenTSFromDB(db *sql.DB, beginTs, endTs time.Time, limit int) ([]taskReportEntry, error) {
- rows, err := db.Query(`
-SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries, SUM(tl.secs_spent) AS secs_spent
-FROM task_log tl
-LEFT JOIN task t ON tl.task_id = t.id
-WHERE tl.end_ts >= ? AND tl.end_ts < ?
-GROUP BY tl.task_id
-ORDER BY t.updated_at ASC
-LIMIT ?;
-`, beginTs.UTC(), endTs.UTC(), limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var tLE []taskReportEntry
-
- for rows.Next() {
- var entry taskReportEntry
- err = rows.Scan(
- &entry.taskId,
- &entry.taskSummary,
- &entry.numEntries,
- &entry.secsSpent,
- )
- if err != nil {
- return nil, err
- }
- tLE = append(tLE, entry)
-
- }
- return tLE, nil
-}
-
-func deleteEntry(db *sql.DB, entry *taskLogEntry) error {
- secsSpent := entry.secsSpent
-
- tx, err := db.Begin()
- if err != nil {
- return err
- }
- defer func() {
- _ = tx.Rollback()
- }()
-
- stmt, err := tx.Prepare(`
-DELETE from task_log
-WHERE ID=?;
-`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- _, err = stmt.Exec(entry.id)
- if err != nil {
- return err
- }
-
- tStmt, err := tx.Prepare(`
-UPDATE task
-SET secs_spent = secs_spent-?,
- updated_at = ?
-WHERE id = ?;
- `)
- if err != nil {
- return err
- }
- defer tStmt.Close()
-
- _, err = tStmt.Exec(secsSpent, time.Now().UTC(), entry.taskId)
- if err != nil {
- return err
- }
-
- err = tx.Commit()
- if err != nil {
- return err
- }
-
- return nil
-}
diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go
deleted file mode 100644
index b8cbfef..0000000
--- a/internal/ui/render_helpers.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package ui
-
-import (
- "fmt"
- "time"
-
- "github.com/dustin/go-humanize"
-)
-
-func (t *task) updateTitle() {
- var trackingIndicator string
- if t.trackingActive {
- trackingIndicator = "⏲ "
- }
-
- t.title = trackingIndicator + t.summary
-}
-
-func (t *task) updateDesc() {
- var timeSpent string
-
- if t.secsSpent != 0 {
- timeSpent = "worked on for " + humanizeDuration(t.secsSpent)
- } else {
- timeSpent = "no time spent"
- }
- lastUpdated := fmt.Sprintf("last updated: %s", humanize.Time(t.updatedAt))
-
- t.desc = fmt.Sprintf("%s %s", RightPadTrim(lastUpdated, 60, true), timeSpent)
-}
-
-func (tl *taskLogEntry) updateTitle() {
- tl.title = Trim(tl.comment, 60)
-}
-
-func (tl *taskLogEntry) updateDesc() {
- timeSpentStr := humanizeDuration(tl.secsSpent)
-
- var timeStr string
- var durationMsg string
-
- endTSRelative := getTSRelative(tl.endTs, time.Now())
-
- switch endTSRelative {
- case tsFromToday:
- durationMsg = fmt.Sprintf("%s ... %s", tl.beginTs.Format(timeOnlyFormat), tl.endTs.Format(timeOnlyFormat))
- case tsFromYesterday:
- durationMsg = "Yesterday"
- case tsFromThisWeek:
- durationMsg = tl.endTs.Format(dayFormat)
- default:
- durationMsg = humanize.Time(tl.endTs)
- }
-
- timeStr = fmt.Sprintf("%s (%s)",
- RightPadTrim(durationMsg, 40, true),
- timeSpentStr)
-
- tl.desc = fmt.Sprintf("%s %s", RightPadTrim("["+tl.taskSummary+"]", 60, true), timeStr)
-}
diff --git a/internal/ui/report.go b/internal/ui/report.go
index 8e4ee1f..f4ee2dc 100644
--- a/internal/ui/report.go
+++ b/internal/ui/report.go
@@ -1,80 +1,85 @@
package ui
import (
"bytes"
"database/sql"
+ "errors"
"fmt"
"io"
- "os"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ pers "github.com/dhth/hours/internal/persistence"
+ "github.com/dhth/hours/internal/types"
+ "github.com/dhth/hours/internal/utils"
"github.com/olekukonko/tablewriter"
)
+var errCouldntGenerateReport = errors.New("couldn't generate report")
+
const (
reportTimeCharsBudget = 6
)
-func RenderReport(db *sql.DB, writer io.Writer, plain bool, period string, agg bool, interactive bool) {
+func RenderReport(db *sql.DB, writer io.Writer, plain bool, period string, agg bool, interactive bool) error {
if period == "" {
- return
+ return nil
}
var fullWeek bool
if interactive {
fullWeek = true
}
- ts, err := getTimePeriod(period, time.Now(), fullWeek)
+ ts, err := types.GetTimePeriod(period, time.Now(), fullWeek)
if err != nil {
- fmt.Printf("error: %s\n", err)
- os.Exit(1)
+ return err
}
var report string
var analyticsType recordsType
if agg {
analyticsType = reportAggRecords
- report, err = getReportAgg(db, ts.start, ts.numDays, plain)
+ report, err = getReportAgg(db, ts.Start, ts.NumDays, plain)
} else {
analyticsType = reportRecords
- report, err = getReport(db, ts.start, ts.numDays, plain)
+ report, err = getReport(db, ts.Start, ts.NumDays, plain)
}
if err != nil {
- fmt.Printf("Something went wrong generating the report: %s\n", err)
+ return fmt.Errorf("%w: %s", errCouldntGenerateReport, err.Error())
}
if interactive {
- p := tea.NewProgram(initialRecordsModel(analyticsType, db, ts.start, ts.end, plain, period, ts.numDays, report))
- if _, err := p.Run(); err != nil {
- fmt.Printf("Alas, there has been an error: %v", err)
- os.Exit(1)
+ p := tea.NewProgram(initialRecordsModel(analyticsType, db, ts.Start, ts.End, plain, period, ts.NumDays, report))
+ _, err := p.Run()
+ if err != nil {
+ return err
}
} else {
fmt.Fprint(writer, report)
}
+ return nil
}
func getReport(db *sql.DB, start time.Time, numDays int, plain bool) (string, error) {
day := start
var nextDay time.Time
var maxEntryForADay int
- reportData := make(map[int][]taskLogEntry)
+ reportData := make(map[int][]types.TaskLogEntry)
noEntriesFound := true
for i := 0; i < numDays; i++ {
nextDay = day.AddDate(0, 0, 1)
- taskLogEntries, err := fetchTLEntriesBetweenTSFromDB(db, day, nextDay, 100)
+ taskLogEntries, err := pers.FetchTLEntriesBetweenTS(db, day, nextDay, 100)
if err != nil {
return "", err
}
if noEntriesFound && len(taskLogEntries) > 0 {
noEntriesFound = false
}
day = nextDay
reportData[i] = taskLogEntries
@@ -107,57 +112,57 @@ func getReport(db *sql.DB, start time.Time, numDays int, plain bool) (string, er
default:
summaryBudget = 16
}
styleCache := make(map[string]lipgloss.Style)
for rowIndex := 0; rowIndex < maxEntryForADay; rowIndex++ {
row := make([]string, numDays)
for colIndex := 0; colIndex < numDays; colIndex++ {
if rowIndex >= len(reportData[colIndex]) {
row[colIndex] = fmt.Sprintf("%s %s",
- RightPadTrim("", summaryBudget, false),
- RightPadTrim("", reportTimeCharsBudget, false),
+ utils.RightPadTrim("", summaryBudget, false),
+ utils.RightPadTrim("", reportTimeCharsBudget, false),
)
continue
}
tr := reportData[colIndex][rowIndex]
- timeSpentStr := humanizeDuration(tr.secsSpent)
+ timeSpentStr := types.HumanizeDuration(tr.SecsSpent)
if plain {
row[colIndex] = fmt.Sprintf("%s %s",
- RightPadTrim(tr.taskSummary, summaryBudget, false),
- RightPadTrim(timeSpentStr, reportTimeCharsBudget, false),
+ utils.RightPadTrim(tr.TaskSummary, summaryBudget, false),
+ utils.RightPadTrim(timeSpentStr, reportTimeCharsBudget, false),
)
} else {
- rowStyle, ok := styleCache[tr.taskSummary]
+ rowStyle, ok := styleCache[tr.TaskSummary]
if !ok {
- rowStyle = getDynamicStyle(tr.taskSummary)
- styleCache[tr.taskSummary] = rowStyle
+ rowStyle = getDynamicStyle(tr.TaskSummary)
+ styleCache[tr.TaskSummary] = rowStyle
}
row[colIndex] = fmt.Sprintf("%s %s",
- rowStyle.Render(RightPadTrim(tr.taskSummary, summaryBudget, false)),
- rowStyle.Render(RightPadTrim(timeSpentStr, reportTimeCharsBudget, false)),
+ rowStyle.Render(utils.RightPadTrim(tr.TaskSummary, summaryBudget, false)),
+ rowStyle.Render(utils.RightPadTrim(timeSpentStr, reportTimeCharsBudget, false)),
)
}
- totalSecsPerDay[colIndex] += tr.secsSpent
+ totalSecsPerDay[colIndex] += tr.SecsSpent
}
data[rowIndex] = row
}
totalTimePerDay := make([]string, numDays)
for i, ts := range totalSecsPerDay {
if ts != 0 {
- totalTimePerDay[i] = rs.footerStyle.Render(humanizeDuration(ts))
+ totalTimePerDay[i] = rs.footerStyle.Render(types.HumanizeDuration(ts))
} else {
totalTimePerDay[i] = " "
}
}
b := bytes.Buffer{}
table := tablewriter.NewWriter(&b)
headersValues := make([]string, numDays)
@@ -188,26 +193,26 @@ func getReport(db *sql.DB, start time.Time, numDays int, plain bool) (string, er
table.Render()
return b.String(), nil
}
func getReportAgg(db *sql.DB, start time.Time, numDays int, plain bool) (string, error) {
day := start
var nextDay time.Time
var maxEntryForADay int
- reportData := make(map[int][]taskReportEntry)
+ reportData := make(map[int][]types.TaskReportEntry)
noEntriesFound := true
for i := 0; i < numDays; i++ {
nextDay = day.AddDate(0, 0, 1)
- taskLogEntries, err := fetchReportBetweenTSFromDB(db, day, nextDay, 100)
+ taskLogEntries, err := pers.FetchReportBetweenTS(db, day, nextDay, 100)
if err != nil {
return "", err
}
if noEntriesFound && len(taskLogEntries) > 0 {
noEntriesFound = false
}
day = nextDay
reportData[i] = taskLogEntries
if len(taskLogEntries) > maxEntryForADay {
@@ -239,54 +244,54 @@ func getReportAgg(db *sql.DB, start time.Time, numDays int, plain bool) (string,
default:
summaryBudget = 16
}
styleCache := make(map[string]lipgloss.Style)
for rowIndex := 0; rowIndex < maxEntryForADay; rowIndex++ {
row := make([]string, numDays)
for colIndex := 0; colIndex < numDays; colIndex++ {
if rowIndex >= len(reportData[colIndex]) {
row[colIndex] = fmt.Sprintf("%s %s",
- RightPadTrim("", summaryBudget, false),
- RightPadTrim("", reportTimeCharsBudget, false),
+ utils.RightPadTrim("", summaryBudget, false),
+ utils.RightPadTrim("", reportTimeCharsBudget, false),
)
continue
}
tr := reportData[colIndex][rowIndex]
- timeSpentStr := humanizeDuration(tr.secsSpent)
+ timeSpentStr := types.HumanizeDuration(tr.SecsSpent)
if plain {
row[colIndex] = fmt.Sprintf("%s %s",
- RightPadTrim(tr.taskSummary, summaryBudget, false),
- RightPadTrim(timeSpentStr, reportTimeCharsBudget, false),
+ utils.RightPadTrim(tr.TaskSummary, summaryBudget, false),
+ utils.RightPadTrim(timeSpentStr, reportTimeCharsBudget, false),
)
} else {
- rowStyle, ok := styleCache[tr.taskSummary]
+ rowStyle, ok := styleCache[tr.TaskSummary]
if !ok {
- rowStyle = getDynamicStyle(tr.taskSummary)
- styleCache[tr.taskSummary] = rowStyle
+ rowStyle = getDynamicStyle(tr.TaskSummary)
+ styleCache[tr.TaskSummary] = rowStyle
}
row[colIndex] = fmt.Sprintf("%s %s",
- rowStyle.Render(RightPadTrim(tr.taskSummary, summaryBudget, false)),
- rowStyle.Render(RightPadTrim(timeSpentStr, reportTimeCharsBudget, false)),
+ rowStyle.Render(utils.RightPadTrim(tr.TaskSummary, summaryBudget, false)),
+ rowStyle.Render(utils.RightPadTrim(timeSpentStr, reportTimeCharsBudget, false)),
)
}
- totalSecsPerDay[colIndex] += tr.secsSpent
+ totalSecsPerDay[colIndex] += tr.SecsSpent
}
data[rowIndex] = row
}
totalTimePerDay := make([]string, numDays)
for i, ts := range totalSecsPerDay {
if ts != 0 {
- totalTimePerDay[i] = rs.footerStyle.Render(humanizeDuration(ts))
+ totalTimePerDay[i] = rs.footerStyle.Render(types.HumanizeDuration(ts))
} else {
totalTimePerDay[i] = " "
}
}
b := bytes.Buffer{}
table := tablewriter.NewWriter(&b)
headersValues := make([]string, numDays)
diff --git a/internal/ui/stats.go b/internal/ui/stats.go
index 2997fe7..1439d56 100644
--- a/internal/ui/stats.go
+++ b/internal/ui/stats.go
@@ -1,136 +1,139 @@
package ui
import (
"bytes"
"database/sql"
+ "errors"
"fmt"
"io"
- "os"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
+ pers "github.com/dhth/hours/internal/persistence"
+ "github.com/dhth/hours/internal/types"
+ "github.com/dhth/hours/internal/utils"
"github.com/olekukonko/tablewriter"
)
+var errCouldntGenerateStats = errors.New("couldn't generate stats")
+
const (
statsLogEntriesLimit = 10000
statsNumDaysUpperBound = 3650
statsTimeCharsBudget = 6
+ periodAll = "all"
)
-func RenderStats(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool) {
+func RenderStats(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool) error {
if period == "" {
- return
+ return nil
}
var stats string
var err error
- if interactive && period == "all" {
- fmt.Print("Interactive mode cannot be used when period='all'\n")
- os.Exit(1)
+ if interactive && period == periodAll {
+ return fmt.Errorf("%w when period=all", errInteractiveModeNotApplicable)
}
- if period == "all" {
+ if period == periodAll {
// TODO: find a better way for this, passing start, end for "all" doesn't make sense
- stats, err = renderStats(db, period, time.Now(), time.Now(), plain)
+ stats, err = getStats(db, period, time.Now(), time.Now(), plain)
if err != nil {
- fmt.Fprintf(writer, "Something went wrong generating the log: %s\n", err)
- os.Exit(1)
+ return fmt.Errorf("%w: %s", errCouldntGenerateStats, err.Error())
}
fmt.Fprint(writer, stats)
- return
+ return nil
}
var fullWeek bool
if interactive {
fullWeek = true
}
- ts, tsErr := getTimePeriod(period, time.Now(), fullWeek)
-
- if tsErr != nil {
- fmt.Printf("error: %s\n", tsErr)
- os.Exit(1)
- }
- stats, err = renderStats(db, period, ts.start, ts.end, plain)
+ ts, err := types.GetTimePeriod(period, time.Now(), fullWeek)
if err != nil {
- fmt.Fprintf(writer, "Something went wrong generating the log: %s\n", err)
- os.Exit(1)
+ return err
+ }
+
+ stats, err = getStats(db, period, ts.Start, ts.End, plain)
+ if err != nil {
+ return fmt.Errorf("%w: %s", errCouldntGenerateStats, err.Error())
}
if interactive {
- p := tea.NewProgram(initialRecordsModel(reportStats, db, ts.start, ts.end, plain, period, ts.numDays, stats))
- if _, err := p.Run(); err != nil {
- fmt.Printf("Alas, there has been an error: %v", err)
- os.Exit(1)
+ p := tea.NewProgram(initialRecordsModel(reportStats, db, ts.Start, ts.End, plain, period, ts.NumDays, stats))
+ _, err := p.Run()
+ if err != nil {
+ return err
}
} else {
fmt.Fprint(writer, stats)
}
+ return nil
}
-func renderStats(db *sql.DB, period string, start, end time.Time, plain bool) (string, error) {
- var entries []taskReportEntry
+func getStats(db *sql.DB, period string, start, end time.Time, plain bool) (string, error) {
+ var entries []types.TaskReportEntry
var err error
- if period == "all" {
- entries, err = fetchStatsFromDB(db, statsLogEntriesLimit)
+ if period == periodAll {
+ entries, err = pers.FetchStats(db, statsLogEntriesLimit)
} else {
- entries, err = fetchStatsBetweenTSFromDB(db, start, end, statsLogEntriesLimit)
+ entries, err = pers.FetchStatsBetweenTS(db, start, end, statsLogEntriesLimit)
}
if err != nil {
return "", err
}
var numEntriesInTable int
if len(entries) == 0 {
numEntriesInTable = 1
} else {
numEntriesInTable = len(entries)
}
data := make([][]string, numEntriesInTable)
if len(entries) == 0 {
data[0] = []string{
- RightPadTrim("", 20, false),
+ utils.RightPadTrim("", 20, false),
"",
- RightPadTrim("", statsTimeCharsBudget, false),
+ utils.RightPadTrim("", statsTimeCharsBudget, false),
}
}
var timeSpentStr string
rs := getReportStyles(plain)
styleCache := make(map[string]lipgloss.Style)
for i, entry := range entries {
- timeSpentStr = humanizeDuration(entry.secsSpent)
+ timeSpentStr = types.HumanizeDuration(entry.SecsSpent)
if plain {
data[i] = []string{
- RightPadTrim(entry.taskSummary, 20, false),
- fmt.Sprintf("%d", entry.numEntries),
- RightPadTrim("", statsTimeCharsBudget, false),
+ utils.RightPadTrim(entry.TaskSummary, 20, false),
+ fmt.Sprintf("%d", entry.NumEntries),
+ utils.RightPadTrim(timeSpentStr, statsTimeCharsBudget, false),
}
} else {
- rowStyle, ok := styleCache[entry.taskSummary]
+ rowStyle, ok := styleCache[entry.TaskSummary]
if !ok {
- rowStyle = getDynamicStyle(entry.taskSummary)
- styleCache[entry.taskSummary] = rowStyle
+ rowStyle = getDynamicStyle(entry.TaskSummary)
+ styleCache[entry.TaskSummary] = rowStyle
}
data[i] = []string{
- rowStyle.Render(RightPadTrim(entry.taskSummary, 20, false)),
- rowStyle.Render(fmt.Sprintf("%d", entry.numEntries)),
- rowStyle.Render(RightPadTrim(timeSpentStr, statsTimeCharsBudget, false)),
+ rowStyle.Render(utils.RightPadTrim(entry.TaskSummary, 20, false)),
+ rowStyle.Render(fmt.Sprintf("%d", entry.NumEntries)),
+ rowStyle.Render(utils.RightPadTrim(timeSpentStr, statsTimeCharsBudget, false)),
}
}
}
b := bytes.Buffer{}
table := tablewriter.NewWriter(&b)
headerValues := []string{"Task", "#LogEntries", "TimeSpent"}
headers := make([]string, len(headerValues))
for i, h := range headerValues {
headers[i] = rs.headerStyle.Render(h)
diff --git a/internal/ui/styles.go b/internal/ui/styles.go
index 2094310..28de6e2 100644
--- a/internal/ui/styles.go
+++ b/internal/ui/styles.go
@@ -23,20 +23,21 @@ const (
recordsFooterColor = "#ef8f62"
recordsBorderColor = "#665c54"
initialHelpMsgColor = "#a58390"
recordsDateRangeColor = "#fabd2f"
recordsHelpColor = "#928374"
helpMsgColor = "#83a598"
helpViewTitleColor = "#83a598"
helpHeaderColor = "#83a598"
helpSectionColor = "#bdae93"
warningColor = "#fb4934"
+ fallbackTaskColor = "#ada7ff"
)
var (
baseStyle = lipgloss.NewStyle().
PaddingLeft(1).
PaddingRight(1).
Foreground(lipgloss.Color(defaultBackgroundColor))
helpMsgStyle = lipgloss.NewStyle().
PaddingLeft(1).
@@ -130,21 +131,26 @@ var (
"#ffb4a2",
"#b8bb26",
"#ffc6ff",
"#4895ef",
"#83a598",
"#fabd2f",
}
getDynamicStyle = func(str string) lipgloss.Style {
h := fnv.New32()
- h.Write([]byte(str))
+ _, err := h.Write([]byte(str))
+ if err != nil {
+ return lipgloss.NewStyle().
+ Foreground(lipgloss.Color(fallbackTaskColor))
+ }
+
hash := h.Sum32()
color := taskColors[hash%uint32(len(taskColors))]
return lipgloss.NewStyle().
Foreground(lipgloss.Color(color))
}
emptyStyle = lipgloss.NewStyle()
WarningStyle = lipgloss.NewStyle().
diff --git a/internal/ui/types.go b/internal/ui/types.go
deleted file mode 100644
index 8ac0ccd..0000000
--- a/internal/ui/types.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package ui
-
-import (
- "time"
-)
-
-type task struct {
- id int
- summary string
- createdAt time.Time
- updatedAt time.Time
- trackingActive bool
- secsSpent int
- active bool
- title string
- desc string
-}
-
-type taskLogEntry struct {
- id int
- taskId int
- taskSummary string
- beginTs time.Time
- endTs time.Time
- secsSpent int
- comment string
- title string
- desc string
-}
-
-type activeTaskDetails struct {
- taskId int
- taskSummary string
- lastLogEntryBeginTs time.Time
-}
-
-type taskReportEntry struct {
- taskId int
- taskSummary string
- numEntries int
- secsSpent int
-}
-
-func (t task) Title() string {
- return t.title
-}
-
-func (t task) Description() string {
- return t.desc
-}
-
-func (t task) FilterValue() string {
- return t.summary
-}
-
-func (e taskLogEntry) Title() string {
- return e.title
-}
-
-func (e taskLogEntry) Description() string {
- return e.desc
-}
-
-func (e taskLogEntry) FilterValue() string {
- return e.comment
-}
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
index 4cac6d8..7171a24 100644
--- a/internal/ui/ui.go
+++ b/internal/ui/ui.go
@@ -1,26 +1,29 @@
package ui
import (
"database/sql"
+ "errors"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
-func RenderUI(db *sql.DB) {
+var errFailedToConfigureDebugging = errors.New("failed to configure debugging")
+
+func RenderUI(db *sql.DB) error {
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
- fmt.Println("fatal:", err)
- os.Exit(1)
+ return fmt.Errorf("%w: %s", errFailedToConfigureDebugging, err.Error())
}
defer f.Close()
}
p := tea.NewProgram(InitialModel(db), tea.WithAltScreen())
- if _, err := p.Run(); err != nil {
- fmt.Printf("Alas, there has been an error: %v", err)
- os.Exit(1)
+ _, err := p.Run()
+ if err != nil {
+ return err
}
+ return nil
}
diff --git a/internal/ui/update.go b/internal/ui/update.go
index ca575f2..f4addc3 100644
--- a/internal/ui/update.go
+++ b/internal/ui/update.go
@@ -1,53 +1,54 @@
package ui
import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/dhth/hours/internal/types"
)
const (
viewPortMoveLineCount = 3
+ msgCouldntSelectATask = "Couldn't select a task"
+ msgChangesLocked = "Changes locked momentarily"
)
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
+
m.message = ""
- switch msg := msg.(type) {
- case tea.KeyMsg:
+ keyMsg, keyMsgOK := msg.(tea.KeyMsg)
+ if keyMsgOK {
if m.activeTasksList.FilterState() == list.Filtering {
m.activeTasksList, cmd = m.activeTasksList.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
- }
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
+ switch keyMsg.String() {
case "enter":
switch m.activeView {
case taskInputView:
m.activeView = activeTaskListView
if m.taskInputs[summaryField].Value() != "" {
switch m.taskMgmtContext {
case taskCreateCxt:
cmds = append(cmds, createTask(m.db, m.taskInputs[summaryField].Value()))
m.taskInputs[summaryField].SetValue("")
case taskUpdateCxt:
- selectedTask, ok := m.activeTasksList.SelectedItem().(*task)
+ selectedTask, ok := m.activeTasksList.SelectedItem().(*types.Task)
if ok {
cmds = append(cmds, updateTask(m.db, selectedTask, m.taskInputs[summaryField].Value()))
m.taskInputs[summaryField].SetValue("")
}
}
return m, tea.Batch(cmds...)
}
case editStartTsView:
beginTS, err := time.ParseInLocation(string(timeFormat), m.trackingInputs[entryBeginTS].Value(), time.Local)
if err != nil {
@@ -78,21 +79,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.activeTLEndTS.Sub(m.activeTLBeginTS).Seconds() <= 0 {
m.message = "time spent needs to be positive"
return m, tea.Batch(cmds...)
}
if m.trackingInputs[entryComment].Value() == "" {
m.message = "Comment cannot be empty"
return m, tea.Batch(cmds...)
}
- cmds = append(cmds, toggleTracking(m.db, m.activeTaskId, m.activeTLBeginTS, m.activeTLEndTS, m.trackingInputs[entryComment].Value()))
+ cmds = append(cmds, toggleTracking(m.db, m.activeTaskID, m.activeTLBeginTS, m.activeTLEndTS, m.trackingInputs[entryComment].Value()))
m.activeView = activeTaskListView
for i := range m.trackingInputs {
m.trackingInputs[i].SetValue("")
}
return m, tea.Batch(cmds...)
case manualTasklogEntryView:
beginTS, err := time.ParseInLocation(string(timeFormat), m.trackingInputs[entryBeginTS].Value(), time.Local)
if err != nil {
@@ -111,49 +112,45 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
comment := m.trackingInputs[entryComment].Value()
if len(comment) == 0 {
m.message = "Comment cannot be empty"
return m, tea.Batch(cmds...)
}
- task, ok := m.activeTasksList.SelectedItem().(*task)
- if ok {
- switch m.tasklogSaveType {
- case tasklogInsert:
- cmds = append(cmds, insertManualEntry(m.db, task.id, beginTS, endTS, comment))
- m.activeView = activeTaskListView
- }
+ task, ok := m.activeTasksList.SelectedItem().(*types.Task)
+ if ok && m.tasklogSaveType == tasklogInsert {
+ cmds = append(cmds, insertManualEntry(m.db, task.ID, beginTS, endTS, comment))
+ m.activeView = activeTaskListView
}
for i := range m.trackingInputs {
m.trackingInputs[i].SetValue("")
}
return m, tea.Batch(cmds...)
}
case "esc":
switch m.activeView {
case taskInputView:
m.activeView = activeTaskListView
for i := range m.taskInputs {
m.taskInputs[i].SetValue("")
}
case editStartTsView:
m.taskInputs[entryBeginTS].SetValue("")
m.activeView = activeTaskListView
case askForCommentView:
m.activeView = activeTaskListView
m.trackingInputs[entryComment].SetValue("")
case manualTasklogEntryView:
- switch m.tasklogSaveType {
- case tasklogInsert:
+ if m.tasklogSaveType == tasklogInsert {
m.activeView = activeTaskListView
}
for i := range m.trackingInputs {
m.trackingInputs[i].SetValue("")
}
}
case "tab":
switch m.activeView {
case activeTaskListView:
m.activeView = taskLogView
@@ -191,36 +188,36 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.trackingFocussedField = entryBeginTS
case entryComment:
m.trackingFocussedField = entryEndTS
}
for i := range m.trackingInputs {
m.trackingInputs[i].Blur()
}
m.trackingInputs[m.trackingFocussedField].Focus()
}
case "k":
- err := m.shiftTime(shiftBackward, shiftMinute)
+ err := m.shiftTime(types.ShiftBackward, types.ShiftMinute)
if err != nil {
return m, tea.Batch(cmds...)
}
case "j":
- err := m.shiftTime(shiftForward, shiftMinute)
+ err := m.shiftTime(types.ShiftForward, types.ShiftMinute)
if err != nil {
return m, tea.Batch(cmds...)
}
case "K":
- err := m.shiftTime(shiftBackward, shiftFiveMinutes)
+ err := m.shiftTime(types.ShiftBackward, types.ShiftFiveMinutes)
if err != nil {
return m, tea.Batch(cmds...)
}
case "J":
- err := m.shiftTime(shiftForward, shiftFiveMinutes)
+ err := m.shiftTime(types.ShiftForward, types.ShiftFiveMinutes)
if err != nil {
return m, tea.Batch(cmds...)
}
}
}
switch m.activeView {
case taskInputView:
for i := range m.taskInputs {
m.taskInputs[i], cmd = m.taskInputs[i].Update(msg)
@@ -298,33 +295,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case inactiveTaskListView:
cmds = append(cmds, fetchTasks(m.db, false))
m.inactiveTasksList.ResetSelected()
}
case "ctrl+t":
if m.activeView == activeTaskListView {
if m.trackingActive {
if m.activeTasksList.IsFiltered() {
m.activeTasksList.ResetFilter()
}
- activeIndex, ok := m.activeTaskIndexMap[m.activeTaskId]
+ activeIndex, ok := m.activeTaskIndexMap[m.activeTaskID]
if ok {
m.activeTasksList.Select(activeIndex)
}
} else {
m.message = "Nothing is being tracked right now"
}
}
case "ctrl+s":
if m.activeView == activeTaskListView {
- _, ok := m.activeTasksList.SelectedItem().(*task)
+ _, ok := m.activeTasksList.SelectedItem().(*types.Task)
if !ok {
- message := "Couldn't select a task"
+ message := msgCouldntSelectATask
m.message = message
m.messages = append(m.messages, message)
} else {
if m.trackingActive {
m.activeView = editStartTsView
m.trackingFocussedField = entryBeginTS
m.trackingInputs[entryBeginTS].SetValue(m.activeTLBeginTS.Format(timeFormat))
m.trackingInputs[m.trackingFocussedField].Focus()
} else {
m.activeView = manualTasklogEntryView
@@ -340,123 +337,120 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
for i := range m.trackingInputs {
m.trackingInputs[i].Blur()
}
m.trackingInputs[m.trackingFocussedField].Focus()
}
}
}
case "ctrl+d":
switch m.activeView {
case activeTaskListView:
- task, ok := m.activeTasksList.SelectedItem().(*task)
+ task, ok := m.activeTasksList.SelectedItem().(*types.Task)
if ok {
- if task.trackingActive {
+ if task.TrackingActive {
m.message = "Cannot deactivate a task being tracked; stop tracking and try again."
} else {
cmds = append(cmds, updateTaskActiveStatus(m.db, task, false))
}
} else {
- msg := "Couldn't select task"
+ msg := msgCouldntSelectATask
m.message = msg
m.messages = append(m.messages, msg)
}
case taskLogView:
- entry, ok := m.taskLogList.SelectedItem().(taskLogEntry)
+ entry, ok := m.taskLogList.SelectedItem().(types.TaskLogEntry)
if ok {
cmds = append(cmds, deleteLogEntry(m.db, &entry))
} else {
msg := "Couldn't delete task log entry"
m.message = msg
m.messages = append(m.messages, msg)
}
case inactiveTaskListView:
- task, ok := m.inactiveTasksList.SelectedItem().(*task)
+ task, ok := m.inactiveTasksList.SelectedItem().(*types.Task)
if ok {
cmds = append(cmds, updateTaskActiveStatus(m.db, task, true))
} else {
- msg := "Couldn't select task"
+ msg := msgCouldntSelectATask
m.message = msg
m.messages = append(m.messages, msg)
}
}
case "ctrl+x":
if m.activeView == activeTaskListView && m.trackingActive {
cmds = append(cmds, deleteActiveTaskLog(m.db))
}
case "s":
- switch m.activeView {
- case activeTaskListView:
+ if m.activeView == activeTaskListView {
if m.activeTasksList.FilterState() != list.Filtering {
if m.changesLocked {
- message := "Changes locked momentarily"
+ message := msgChangesLocked
m.message = message
m.messages = append(m.messages, message)
}
- task, ok := m.activeTasksList.SelectedItem().(*task)
+ task, ok := m.activeTasksList.SelectedItem().(*types.Task)
if !ok {
message := "Couldn't select a task"
m.message = message
m.messages = append(m.messages, message)
} else {
if m.lastChange == updateChange {
m.changesLocked = true
m.activeTLBeginTS = time.Now()
- cmds = append(cmds, toggleTracking(m.db, task.id, m.activeTLBeginTS, m.activeTLEndTS, ""))
+ cmds = append(cmds, toggleTracking(m.db, task.ID, m.activeTLBeginTS, m.activeTLEndTS, ""))
} else if m.lastChange == insertChange {
m.activeView = askForCommentView
m.activeTLEndTS = time.Now()
beginTimeStr := m.activeTLBeginTS.Format(timeFormat)
currentTimeStr := m.activeTLEndTS.Format(timeFormat)
m.trackingInputs[entryBeginTS].SetValue(beginTimeStr)
m.trackingInputs[entryEndTS].SetValue(currentTimeStr)
m.trackingFocussedField = entryComment
for i := range m.trackingInputs {
m.trackingInputs[i].Blur()
}
m.trackingInputs[m.trackingFocussedField].Focus()
}
}
}
}
case "a":
- switch m.activeView {
- case activeTaskListView:
+ if m.activeView == activeTaskListView {
if m.activeTasksList.FilterState() != list.Filtering {
if m.changesLocked {
- message := "Changes locked momentarily"
+ message := msgChangesLocked
m.message = message
m.messages = append(m.messages, message)
}
m.activeView = taskInputView
m.taskInputFocussedField = summaryField
m.taskInputs[summaryField].Focus()
m.taskMgmtContext = taskCreateCxt
}
}
case "u":
- switch m.activeView {
- case activeTaskListView:
+ if m.activeView == activeTaskListView {
if m.activeTasksList.FilterState() != list.Filtering {
if m.changesLocked {
- message := "Changes locked momentarily"
+ message := msgChangesLocked
m.message = message
m.messages = append(m.messages, message)
}
- task, ok := m.activeTasksList.SelectedItem().(*task)
+ task, ok := m.activeTasksList.SelectedItem().(*types.Task)
if ok {
m.activeView = taskInputView
m.taskInputFocussedField = summaryField
m.taskInputs[summaryField].Focus()
- m.taskInputs[summaryField].SetValue(task.summary)
+ m.taskInputs[summaryField].SetValue(task.Summary)
m.taskMgmtContext = taskUpdateCxt
} else {
m.message = "Couldn't select a task"
}
}
}
case "k":
if m.activeView != helpView {
break
@@ -507,49 +501,49 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case taskCreatedMsg:
if msg.err != nil {
m.message = fmt.Sprintf("Error creating task: %s", msg.err)
} else {
cmds = append(cmds, fetchTasks(m.db, true))
}
case taskUpdatedMsg:
if msg.err != nil {
m.message = fmt.Sprintf("Error updating task: %s", msg.err)
} else {
- msg.tsk.summary = msg.summary
- msg.tsk.updateTitle()
+ msg.tsk.Summary = msg.summary
+ msg.tsk.UpdateTitle()
}
case tasksFetched:
if msg.err != nil {
message := "error fetching tasks : " + msg.err.Error()
m.message = message
m.messages = append(m.messages, message)
} else {
if msg.active {
- m.activeTaskMap = make(map[int]*task)
+ m.activeTaskMap = make(map[int]*types.Task)
m.activeTaskIndexMap = make(map[int]int)
tasks := make([]list.Item, len(msg.tasks))
for i, task := range msg.tasks {
- task.updateTitle()
- task.updateDesc()
+ task.UpdateTitle()
+ task.UpdateDesc()
tasks[i] = &task
- m.activeTaskMap[task.id] = &task
- m.activeTaskIndexMap[task.id] = i
+ m.activeTaskMap[task.ID] = &task
+ m.activeTaskIndexMap[task.ID] = i
}
m.activeTasksList.SetItems(tasks)
m.activeTasksList.Title = "Tasks"
m.tasksFetched = true
cmds = append(cmds, fetchActiveTask(m.db))
} else {
inactiveTasks := make([]list.Item, len(msg.tasks))
for i, inactiveTask := range msg.tasks {
- inactiveTask.updateTitle()
- inactiveTask.updateDesc()
+ inactiveTask.UpdateTitle()
+ inactiveTask.UpdateDesc()
inactiveTasks[i] = &inactiveTask
}
m.inactiveTasksList.SetItems(inactiveTasks)
}
}
case tlBeginTSUpdatedMsg:
if msg.err != nil {
message := msg.err.Error()
m.message = "Error updating begin time: " + message
m.messages = append(m.messages, message)
@@ -558,125 +552,125 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case manualTaskLogInserted:
if msg.err != nil {
message := msg.err.Error()
m.message = "Error inserting task log: " + message
m.messages = append(m.messages, message)
} else {
for i := range m.trackingInputs {
m.trackingInputs[i].SetValue("")
}
- task, ok := m.activeTaskMap[msg.taskId]
+ task, ok := m.activeTaskMap[msg.taskID]
if ok {
cmds = append(cmds, updateTaskRep(m.db, task))
}
cmds = append(cmds, fetchTaskLogEntries(m.db))
}
case taskLogEntriesFetchedMsg:
if msg.err != nil {
message := msg.err.Error()
m.message = "Error fetching task log entries: " + message
m.messages = append(m.messages, message)
} else {
var items []list.Item
for _, e := range msg.entries {
- e.updateTitle()
- e.updateDesc()
+ e.UpdateTitle()
+ e.UpdateDesc()
items = append(items, e)
}
m.taskLogList.SetItems(items)
}
case activeTaskFetchedMsg:
if msg.err != nil {
message := msg.err.Error()
m.message = message
m.messages = append(m.messages, message)
} else {
if msg.noneActive {
m.lastChange = updateChange
} else {
- m.activeTaskId = msg.activeTaskId
+ m.activeTaskID = msg.activeTaskID
m.lastChange = insertChange
m.activeTLBeginTS = msg.beginTs
- activeTask, ok := m.activeTaskMap[m.activeTaskId]
+ activeTask, ok := m.activeTaskMap[m.activeTaskID]
if ok {
- activeTask.trackingActive = true
- activeTask.updateTitle()
+ activeTask.TrackingActive = true
+ activeTask.UpdateTitle()
// go to tracked item on startup
- activeIndex, ok := m.activeTaskIndexMap[msg.activeTaskId]
+ activeIndex, ok := m.activeTaskIndexMap[msg.activeTaskID]
if ok {
m.activeTasksList.Select(activeIndex)
}
}
m.trackingActive = true
}
}
case trackingToggledMsg:
if msg.err != nil {
message := msg.err.Error()
m.message = message
m.messages = append(m.messages, message)
m.trackingActive = false
} else {
m.changesLocked = false
- task, ok := m.activeTaskMap[msg.taskId]
+ task, ok := m.activeTaskMap[msg.taskID]
if ok {
if msg.finished {
m.lastChange = updateChange
- task.trackingActive = false
+ task.TrackingActive = false
m.trackingActive = false
- m.activeTaskId = -1
+ m.activeTaskID = -1
cmds = append(cmds, updateTaskRep(m.db, task))
cmds = append(cmds, fetchTaskLogEntries(m.db))
} else {
m.lastChange = insertChange
- task.trackingActive = true
+ task.TrackingActive = true
m.trackingActive = true
- m.activeTaskId = msg.taskId
+ m.activeTaskID = msg.taskID
}
- task.updateTitle()
+ task.UpdateTitle()
}
}
case taskRepUpdatedMsg:
if msg.err != nil {
m.message = fmt.Sprintf("Error updating task status: %s", msg.err)
} else {
- msg.tsk.updateDesc()
+ msg.tsk.UpdateDesc()
}
case taskLogEntryDeletedMsg:
if msg.err != nil {
message := "error deleting entry: " + msg.err.Error()
m.message = message
m.messages = append(m.messages, message)
} else {
- task, ok := m.activeTaskMap[msg.entry.taskId]
+ task, ok := m.activeTaskMap[msg.entry.TaskID]
if ok {
cmds = append(cmds, updateTaskRep(m.db, task))
}
cmds = append(cmds, fetchTaskLogEntries(m.db))
}
case activeTaskLogDeletedMsg:
if msg.err != nil {
m.message = fmt.Sprintf("Error deleting active log entry: %s", msg.err)
} else {
- activeTask, ok := m.activeTaskMap[m.activeTaskId]
+ activeTask, ok := m.activeTaskMap[m.activeTaskID]
if ok {
- activeTask.trackingActive = false
- activeTask.updateTitle()
+ activeTask.TrackingActive = false
+ activeTask.UpdateTitle()
}
m.lastChange = updateChange
m.trackingActive = false
- m.activeTaskId = -1
+ m.activeTaskID = -1
}
case taskActiveStatusUpdated:
if msg.err != nil {
message := "error updating task's active status: " + msg.err.Error()
m.message = message
m.messages = append(m.messages, message)
} else {
cmds = append(cmds, fetchTasks(m.db, true))
cmds = append(cmds, fetchTasks(m.db, false))
}
@@ -709,62 +703,62 @@ func (m recordsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
m.quitting = true
return m, tea.Quit
case "left", "h":
if !m.busy {
var newStart, newEnd time.Time
var numDays int
switch m.period {
- case "week":
+ case types.TimePeriodWeek:
weekday := m.start.Weekday()
offset := (7 + weekday - time.Monday) % 7
startOfPrevWeek := m.start.AddDate(0, 0, -int(offset+7))
newStart = time.Date(startOfPrevWeek.Year(), startOfPrevWeek.Month(), startOfPrevWeek.Day(), 0, 0, 0, 0, startOfPrevWeek.Location())
numDays = 7
default:
newStart = m.start.AddDate(0, 0, -m.numDays)
numDays = m.numDays
}
newEnd = newStart.AddDate(0, 0, numDays)
cmds = append(cmds, getRecordsData(m.typ, m.db, m.period, newStart, newEnd, numDays, m.plain))
m.busy = true
}
case "right", "l":
if !m.busy {
var newStart, newEnd time.Time
var numDays int
switch m.period {
- case "week":
+ case types.TimePeriodWeek:
weekday := m.start.Weekday()
offset := (7 + weekday - time.Monday) % 7
startOfNextWeek := m.start.AddDate(0, 0, 7-int(offset))
newStart = time.Date(startOfNextWeek.Year(), startOfNextWeek.Month(), startOfNextWeek.Day(), 0, 0, 0, 0, startOfNextWeek.Location())
numDays = 7
default:
newStart = m.start.AddDate(0, 0, 1*(m.numDays))
numDays = m.numDays
}
newEnd = newStart.AddDate(0, 0, numDays)
cmds = append(cmds, getRecordsData(m.typ, m.db, m.period, newStart, newEnd, numDays, m.plain))
m.busy = true
}
case "ctrl+t":
if !m.busy {
var start, end time.Time
var numDays int
switch m.period {
- case "week":
+ case types.TimePeriodWeek:
now := time.Now()
weekday := now.Weekday()
offset := (7 + weekday - time.Monday) % 7
startOfWeek := now.AddDate(0, 0, -int(offset))
start = time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location())
numDays = 7
default:
now := time.Now()
nDaysBack := now.AddDate(0, 0, -1*(m.numDays-1))
@@ -784,25 +778,25 @@ func (m recordsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.start = msg.start
m.end = msg.end
m.report = msg.report
m.busy = false
}
}
return m, tea.Batch(cmds...)
}
-func (m model) shiftTime(direction timeShiftDirection, duration timeShiftDuration) error {
+func (m Model) shiftTime(direction types.TimeShiftDirection, duration types.TimeShiftDuration) error {
if m.activeView == editStartTsView || m.activeView == askForCommentView || m.activeView == manualTasklogEntryView {
if m.trackingFocussedField == entryBeginTS || m.trackingFocussedField == entryEndTS {
ts, err := time.ParseInLocation(string(timeFormat), m.trackingInputs[m.trackingFocussedField].Value(), time.Local)
if err != nil {
return err
}
- newTs := getShiftedTime(ts, direction, duration)
+ newTs := types.GetShiftedTime(ts, direction, duration)
m.trackingInputs[m.trackingFocussedField].SetValue(newTs.Format(timeFormat))
}
}
return nil
}
diff --git a/internal/ui/utils.go b/internal/ui/utils.go
index 1816a08..d4fe366 100644
--- a/internal/ui/utils.go
+++ b/internal/ui/utils.go
@@ -1,67 +1,22 @@
package ui
import (
- "fmt"
- "math"
- "strings"
- "time"
-
"github.com/charmbracelet/lipgloss"
)
type reportStyles struct {
headerStyle lipgloss.Style
footerStyle lipgloss.Style
borderStyle lipgloss.Style
}
-func RightPadTrim(s string, length int, dots bool) string {
- if len(s) >= length {
- if dots && length > 3 {
- return s[:length-3] + "..."
- }
- return s[:length]
- }
- return s + strings.Repeat(" ", length-len(s))
-}
-
-func Trim(s string, length int) string {
- if len(s) >= length {
- if length > 3 {
- return s[:length-3] + "..."
- }
- return s[:length]
- }
- return s
-}
-
-func humanizeDuration(durationInSecs int) string {
- duration := time.Duration(durationInSecs) * time.Second
-
- if duration.Seconds() < 60 {
- return fmt.Sprintf("%ds", int(duration.Seconds()))
- }
-
- if duration.Minutes() < 60 {
- return fmt.Sprintf("%dm", int(duration.Minutes()))
- }
-
- modMins := int(math.Mod(duration.Minutes(), 60))
-
- if modMins == 0 {
- return fmt.Sprintf("%dh", int(duration.Hours()))
- }
-
- return fmt.Sprintf("%dh %dm", int(duration.Hours()), modMins)
-}
-
func getReportStyles(plain bool) reportStyles {
if plain {
return reportStyles{
emptyStyle,
emptyStyle,
emptyStyle,
}
}
return reportStyles{
recordsHeaderStyle,
diff --git a/internal/ui/view.go b/internal/ui/view.go
index 1726016..9121577 100644
--- a/internal/ui/view.go
+++ b/internal/ui/view.go
@@ -1,39 +1,40 @@
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
+ "github.com/dhth/hours/internal/utils"
)
const (
taskLogEntryViewHeading = "Task Log Entry"
)
var listWidth = 140
-func (m model) View() string {
+func (m Model) View() string {
var content string
var footer string
var statusBar string
if m.message != "" {
- statusBar = Trim(m.message, 120)
+ statusBar = utils.Trim(m.message, 120)
}
var activeMsg string
if m.tasksFetched && m.trackingActive {
var taskSummaryMsg, taskStartedSinceMsg string
- task, ok := m.activeTaskMap[m.activeTaskId]
+ task, ok := m.activeTaskMap[m.activeTaskID]
if ok {
- taskSummaryMsg = Trim(task.summary, 50)
+ taskSummaryMsg = utils.Trim(task.Summary, 50)
if m.activeView != askForCommentView {
taskStartedSinceMsg = fmt.Sprintf("(since %s)", m.activeTLBeginTS.Format(timeOnlyFormat))
}
}
activeMsg = fmt.Sprintf("%s%s%s",
trackingStyle.Render("tracking:"),
activeTaskSummaryMsgStyle.Render(taskSummaryMsg),
activeTaskBeginTimeStyle.Render(taskStartedSinceMsg),
)
}
@@ -97,21 +98,21 @@ func (m model) View() string {
`,
taskLogEntryHeadingStyle.Render(taskLogEntryViewHeading),
formContextStyle.Render(formHeadingText),
formHelpStyle.Render("Use tab/shift-tab to move between sections; esc to go back."),
formFieldNameStyle.Render("Begin Time (format: 2006/01/02 15:04)"),
m.trackingInputs[entryBeginTS].View(),
formHelpStyle.Render("(k/j/K/J moves time, when correct)"),
formFieldNameStyle.Render("End Time (format: 2006/01/02 15:04)"),
m.trackingInputs[entryEndTS].View(),
formHelpStyle.Render("(k/j/K/J moves time, when correct)"),
- formFieldNameStyle.Render(RightPadTrim("Comment:", 16, true)),
+ formFieldNameStyle.Render(utils.RightPadTrim("Comment:", 16, true)),
m.trackingInputs[entryComment].View(),
formHelpStyle.Render("Press enter to submit"),
)
for i := 0; i < m.terminalHeight-24; i++ {
content += "\n"
}
case editStartTsView:
formHeadingText := "Updating log entry. Enter the following details."
content = fmt.Sprintf(
@@ -171,21 +172,21 @@ func (m model) View() string {
`,
taskLogEntryHeadingStyle.Render(taskLogEntryViewHeading),
formContextStyle.Render(formHeadingText),
formHelpStyle.Render("Use tab/shift-tab to move between sections; esc to go back."),
formFieldNameStyle.Render("Begin Time (format: 2006/01/02 15:04)"),
m.trackingInputs[entryBeginTS].View(),
formHelpStyle.Render("(k/j/K/J moves time, when correct)"),
formFieldNameStyle.Render("End Time (format: 2006/01/02 15:04)"),
m.trackingInputs[entryEndTS].View(),
formHelpStyle.Render("(k/j/K/J moves time, when correct)"),
- formFieldNameStyle.Render(RightPadTrim("Comment:", 16, true)),
+ formFieldNameStyle.Render(utils.RightPadTrim("Comment:", 16, true)),
m.trackingInputs[entryComment].View(),
formHelpStyle.Render("Press enter to submit"),
)
for i := 0; i < m.terminalHeight-24; i++ {
content += "\n"
}
case helpView:
if !m.helpVPReady {
content = "\n Initializing..."
} else {
diff --git a/internal/utils/utils.go b/internal/utils/utils.go
new file mode 100644
index 0000000..afec04b
--- /dev/null
+++ b/internal/utils/utils.go
@@ -0,0 +1,23 @@
+package utils
+
+import "strings"
+
+func RightPadTrim(s string, length int, dots bool) string {
+ if len(s) >= length {
+ if dots && length > 3 {
+ return s[:length-3] + "..."
+ }
+ return s[:length]
+ }
+ return s + strings.Repeat(" ", length-len(s))
+}
+
+func Trim(s string, length int) string {
+ if len(s) >= length {
+ if length > 3 {
+ return s[:length-3] + "..."
+ }
+ return s[:length]
+ }
+ return s
+}
diff --git a/main.go b/main.go
index 3fc0abd..59a81c0 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,14 @@
package main
-import "github.com/dhth/hours/cmd"
+import (
+ "os"
+
+ "github.com/dhth/hours/cmd"
+)
func main() {
- cmd.Execute()
+ err := cmd.Execute()
+ if err != nil {
+ os.Exit(1)
+ }
}
👉 The "distilled" version of the same diff looks like the following:
expand
diff --git a/ac7d86e/cmd/db.go b/ac7d86e/cmd/db.go
deleted file mode 100644
index 3e1ef28..0000000
--- a/ac7d86e/cmd/db.go
+++ /dev/null
@@ -1,2 +0,0 @@
-func getDB(dbpath string) (*sql.DB, error)
-func initDB(db *sql.DB) error
diff --git a/ac7d86e/cmd/db_migrations_test.go b/ac7d86e/cmd/db_migrations_test.go
deleted file mode 100644
index 72d6fbd..0000000
--- a/ac7d86e/cmd/db_migrations_test.go
+++ /dev/null
@@ -1 +0,0 @@
-func TestMigrationsAreSetupCorrectly(t *testing.T)
diff --git a/ac7d86e/cmd/root.go b/53b6ad1/cmd/root.go
index 92552b7..2dd1453 100644
--- a/ac7d86e/cmd/root.go
+++ b/53b6ad1/cmd/root.go
@@ -1,4 +1,3 @@
-func die(msg string, args ...any)
-func setupDB()
-func init()
-func Execute()
+func Execute() error
+func setupDB(dbPathFull string) (*sql.DB, error)
+func NewRootCommand() (*cobra.Command, error)
@@ -6 +5 @@ func getRandomChars(length int) string
-func getConfirmation() bool
+func getConfirmation() (bool, error)
diff --git a/ac7d86e/cmd/utils.go b/53b6ad1/cmd/utils.go
index 9dc7039..9127755 100644
--- a/ac7d86e/cmd/utils.go
+++ b/53b6ad1/cmd/utils.go
@@ -1 +1 @@
-func expandTilde(path string) string
+func expandTilde(path string, homeDir string) string
diff --git a/53b6ad1/cmd/utils_test.go b/53b6ad1/cmd/utils_test.go
new file mode 100644
index 0000000..ce52ef3
--- /dev/null
+++ b/53b6ad1/cmd/utils_test.go
@@ -0,0 +1 @@
+func TestExpandTilde(t *testing.T)
diff --git a/53b6ad1/internal/persistence/init.go b/53b6ad1/internal/persistence/init.go
new file mode 100644
index 0000000..fa4accf
--- /dev/null
+++ b/53b6ad1/internal/persistence/init.go
@@ -0,0 +1 @@
+func InitDB(db *sql.DB) error
diff --git a/ac7d86e/cmd/db_migrations.go b/53b6ad1/internal/persistence/migrations.go
similarity index 72%
rename from ac7d86e/cmd/db_migrations.go
rename to 53b6ad1/internal/persistence/migrations.go
index 1ada63d..654706a 100644
--- a/ac7d86e/cmd/db_migrations.go
+++ b/53b6ad1/internal/persistence/migrations.go
@@ -8,2 +8,2 @@ func fetchLatestDBVersion(db *sql.DB) (dbVersionInfo, error)
-func upgradeDBIfNeeded(db *sql.DB)
-func upgradeDB(db *sql.DB, currentVersion int)
+func UpgradeDBIfNeeded(db *sql.DB) error
+func UpgradeDB(db *sql.DB, currentVersion int) error
diff --git a/53b6ad1/internal/persistence/migrations_test.go b/53b6ad1/internal/persistence/migrations_test.go
new file mode 100644
index 0000000..9b71efb
--- /dev/null
+++ b/53b6ad1/internal/persistence/migrations_test.go
@@ -0,0 +1,3 @@
+func TestMigrationsAreSetupCorrectly(t *testing.T)
+func TestMigrationsWork(t *testing.T)
+func TestRunMigrationFailsWhenGivenBadMigration(t *testing.T)
diff --git a/53b6ad1/internal/persistence/open.go b/53b6ad1/internal/persistence/open.go
new file mode 100644
index 0000000..e2b6962
--- /dev/null
+++ b/53b6ad1/internal/persistence/open.go
@@ -0,0 +1 @@
+func GetDB(dbpath string) (*sql.DB, error)
diff --git a/53b6ad1/internal/persistence/queries.go b/53b6ad1/internal/persistence/queries.go
new file mode 100644
index 0000000..e6711eb
--- /dev/null
+++ b/53b6ad1/internal/persistence/queries.go
@@ -0,0 +1,21 @@
+func InsertNewTL(db *sql.DB, taskID int, beginTs time.Time) (int, error)
+func UpdateTLBeginTS(db *sql.DB, beginTs time.Time) error
+func DeleteActiveTL(db *sql.DB) error
+func UpdateActiveTL(db *sql.DB, taskLogID int, taskID int, beginTs, endTs time.Time, secsSpent int, comment string) error
+func InsertManualTL(db *sql.DB, taskID int, beginTs time.Time, endTs time.Time, comment string) (int, error)
+func FetchActiveTask(db *sql.DB) (types.ActiveTaskDetails, error)
+func InsertTask(db *sql.DB, summary string) (int, error)
+func UpdateTask(db *sql.DB, id int, summary string) error
+func UpdateTaskActiveStatus(db *sql.DB, id int, active bool) error
+func UpdateTaskData(db *sql.DB, t *types.Task) error
+func FetchTasks(db *sql.DB, active bool, limit int) ([]types.Task, error)
+func FetchTLEntries(db *sql.DB, desc bool, limit int) ([]types.TaskLogEntry, error)
+func FetchTLEntriesBetweenTS(db *sql.DB, beginTs, endTs time.Time, limit int) ([]types.TaskLogEntry, error)
+func FetchStats(db *sql.DB, limit int) ([]types.TaskReportEntry, error)
+func FetchStatsBetweenTS(db *sql.DB, beginTs, endTs time.Time, limit int) ([]types.TaskReportEntry, error)
+func FetchReportBetweenTS(db *sql.DB, beginTs, endTs time.Time, limit int) ([]types.TaskReportEntry, error)
+func DeleteTaskLogEntry(db *sql.DB, entry *types.TaskLogEntry) error
+func runInTxAndReturnID(db *sql.DB, fn func(tx *sql.Tx) (int, error)) (int, error)
+func runInTx(db *sql.DB, fn func(tx *sql.Tx) error) error
+func fetchTaskByID(db *sql.DB, id int) (types.Task, error)
+func fetchTaskLogByID(db *sql.DB, id int) (types.TaskLogEntry, error)
diff --git a/53b6ad1/internal/persistence/queries_test.go b/53b6ad1/internal/persistence/queries_test.go
new file mode 100644
index 0000000..50c2dd1
--- /dev/null
+++ b/53b6ad1/internal/persistence/queries_test.go
@@ -0,0 +1,8 @@
+type testData struct {
+ tasks []types.Task
+ taskLogs []types.TaskLogEntry
+}
+func TestRepository(t *testing.T)
+func cleanupDB(t *testing.T, testDB *sql.DB)
+func getTestData(referenceTS time.Time) testData
+func seedDB(t *testing.T, db *sql.DB, data testData)
diff --git a/53b6ad1/internal/types/date_helpers.go b/53b6ad1/internal/types/date_helpers.go
new file mode 100644
index 0000000..29f70d0
--- /dev/null
+++ b/53b6ad1/internal/types/date_helpers.go
@@ -0,0 +1,10 @@
+type TimePeriod struct {
+ Start time.Time
+ End time.Time
+ NumDays int
+}
+type tsRelative uint8
+func parseDateDuration(dateRange string) (TimePeriod, error)
+func GetTimePeriod(period string, now time.Time, fullWeek bool) (TimePeriod, error)
+func GetShiftedTime(ts time.Time, direction TimeShiftDirection, duration TimeShiftDuration) time.Time
+func getTSRelative(ts time.Time, reference time.Time) tsRelative
diff --git a/ac7d86e/internal/ui/date_helpers_test.go b/53b6ad1/internal/types/date_helpers_test.go
similarity index 100%
rename from ac7d86e/internal/ui/date_helpers_test.go
rename to 53b6ad1/internal/types/date_helpers_test.go
diff --git a/53b6ad1/internal/types/types.go b/53b6ad1/internal/types/types.go
new file mode 100644
index 0000000..0731bd1
--- /dev/null
+++ b/53b6ad1/internal/types/types.go
@@ -0,0 +1,46 @@
+type Task struct {
+ ID int
+ Summary string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ TrackingActive bool
+ SecsSpent int
+ Active bool
+ TaskTitle string
+ TaskDesc string
+}
+type TaskLogEntry struct {
+ ID int
+ TaskID int
+ TaskSummary string
+ BeginTS time.Time
+ EndTS time.Time
+ SecsSpent int
+ Comment string
+ TLTitle string
+ TLDesc string
+}
+type ActiveTaskDetails struct {
+ TaskID int
+ TaskSummary string
+ LastLogEntryBeginTS time.Time
+}
+type TaskReportEntry struct {
+ TaskID int
+ TaskSummary string
+ NumEntries int
+ SecsSpent int
+}
+type TimeShiftDirection uint8
+type TimeShiftDuration uint8
+func HumanizeDuration(durationInSecs int) string
+func (t *Task) UpdateTitle()
+func (t *Task) UpdateDesc()
+func (tl *TaskLogEntry) UpdateTitle()
+func (tl *TaskLogEntry) UpdateDesc()
+func (t Task) Title() string
+func (t Task) Description() string
+func (t Task) FilterValue() string
+func (tl TaskLogEntry) Title() string
+func (tl TaskLogEntry) Description() string
+func (tl TaskLogEntry) FilterValue() string
diff --git a/53b6ad1/internal/types/types_test.go b/53b6ad1/internal/types/types_test.go
new file mode 100644
index 0000000..9d3c315
--- /dev/null
+++ b/53b6ad1/internal/types/types_test.go
@@ -0,0 +1 @@
+func TestHumanizeDuration(t *testing.T)
diff --git a/ac7d86e/internal/ui/active.go b/53b6ad1/internal/ui/active.go
index 3a476ba..7c31fa2 100644
--- a/ac7d86e/internal/ui/active.go
+++ b/53b6ad1/internal/ui/active.go
@@ -1 +1 @@
-func ShowActiveTask(db *sql.DB, writer io.Writer, template string)
+func ShowActiveTask(db *sql.DB, writer io.Writer, template string) error
diff --git a/ac7d86e/internal/ui/cmds.go b/53b6ad1/internal/ui/cmds.go
index e34d42c..16a5119 100644
--- a/ac7d86e/internal/ui/cmds.go
+++ b/53b6ad1/internal/ui/cmds.go
@@ -2 +2 @@ func toggleTracking(db *sql.DB,
- taskId int,
+ taskID int,
@@ -8 +8 @@ func updateTLBeginTS(db *sql.DB, beginTS time.Time) tea.Cmd
-func insertManualEntry(db *sql.DB, taskId int, beginTS time.Time, endTS time.Time, comment string) tea.Cmd
+func insertManualEntry(db *sql.DB, taskID int, beginTS time.Time, endTS time.Time, comment string) tea.Cmd
@@ -10 +10 @@ func fetchActiveTask(db *sql.DB) tea.Cmd
-func updateTaskRep(db *sql.DB, t *task) tea.Cmd
+func updateTaskRep(db *sql.DB, t *types.Task) tea.Cmd
@@ -12 +12 @@ func fetchTaskLogEntries(db *sql.DB) tea.Cmd
-func deleteLogEntry(db *sql.DB, entry *taskLogEntry) tea.Cmd
+func deleteLogEntry(db *sql.DB, entry *types.TaskLogEntry) tea.Cmd
@@ -15,2 +15,2 @@ func createTask(db *sql.DB, summary string) tea.Cmd
-func updateTask(db *sql.DB, task *task, summary string) tea.Cmd
-func updateTaskActiveStatus(db *sql.DB, task *task, active bool) tea.Cmd
+func updateTask(db *sql.DB, task *types.Task, summary string) tea.Cmd
+func updateTaskActiveStatus(db *sql.DB, task *types.Task, active bool) tea.Cmd
diff --git a/ac7d86e/internal/ui/date_helpers.go b/ac7d86e/internal/ui/date_helpers.go
deleted file mode 100644
index b41d9d2..0000000
--- a/ac7d86e/internal/ui/date_helpers.go
+++ /dev/null
@@ -1,12 +0,0 @@
-type timePeriod struct {
- start time.Time
- end time.Time
- numDays int
-}
-type timeShiftDirection uint8
-type timeShiftDuration uint8
-type tsRelative uint8
-func parseDateDuration(dateRange string) (timePeriod, bool)
-func getTimePeriod(period string, now time.Time, fullWeek bool) (timePeriod, error)
-func getShiftedTime(ts time.Time, direction timeShiftDirection, duration timeShiftDuration) time.Time
-func getTSRelative(ts time.Time, reference time.Time) tsRelative
diff --git a/ac7d86e/internal/ui/initial.go b/53b6ad1/internal/ui/initial.go
index 544b630..4d99dc0 100644
--- a/ac7d86e/internal/ui/initial.go
+++ b/53b6ad1/internal/ui/initial.go
@@ -1 +1 @@
-func InitialModel(db *sql.DB) model
+func InitialModel(db *sql.DB) Model
diff --git a/ac7d86e/internal/ui/log.go b/53b6ad1/internal/ui/log.go
index 96fb1e0..3a914df 100644
--- a/ac7d86e/internal/ui/log.go
+++ b/53b6ad1/internal/ui/log.go
@@ -1,2 +1,2 @@
-func RenderTaskLog(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool)
-func renderTaskLog(db *sql.DB, start, end time.Time, limit int, plain bool) (string, error)
+func RenderTaskLog(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool) error
+func getTaskLog(db *sql.DB, start, end time.Time, limit int, plain bool) (string, error)
diff --git a/ac7d86e/internal/ui/model.go b/53b6ad1/internal/ui/model.go
index 8050cc3..bc734f4 100644
--- a/ac7d86e/internal/ui/model.go
+++ b/53b6ad1/internal/ui/model.go
@@ -9 +9 @@ type recordsType uint
-type model struct {
+type Model struct {
@@ -15 +15 @@ type model struct {
- activeTaskMap map[int]*task
+ activeTaskMap map[int]*types.Task
@@ -30 +30 @@ type model struct {
- activeTaskId int
+ activeTaskID int
@@ -51,2 +51,2 @@ type recordsModel struct {
-func (m model) Init() tea.Cmd
-func (m recordsModel) Init() tea.Cmd
+func (m Model) Init() tea.Cmd
+func (recordsModel) Init() tea.Cmd
diff --git a/ac7d86e/internal/ui/msgs.go b/53b6ad1/internal/ui/msgs.go
index 5b11545..b032a77 100644
--- a/ac7d86e/internal/ui/msgs.go
+++ b/53b6ad1/internal/ui/msgs.go
@@ -3 +3 @@ type trackingToggledMsg struct {
- taskId int
+ taskID int
@@ -9 +9 @@ type taskRepUpdatedMsg struct {
- tsk *task
+ tsk *types.Task
@@ -13 +13 @@ type manualTaskLogInserted struct {
- taskId int
+ taskID int
@@ -24 +24 @@ type activeTaskFetchedMsg struct {
- activeTaskId int
+ activeTaskID int
@@ -30 +30 @@ type taskLogEntriesFetchedMsg struct {
- entries []taskLogEntry
+ entries []types.TaskLogEntry
@@ -37 +37 @@ type taskUpdatedMsg struct {
- tsk *task
+ tsk *types.Task
@@ -42 +42 @@ type taskActiveStatusUpdated struct {
- tsk *task
+ tsk *types.Task
@@ -47 +47 @@ type taskLogEntryDeletedMsg struct {
- entry *taskLogEntry
+ entry *types.TaskLogEntry
@@ -51 +51 @@ type tasksFetched struct {
- tasks []task
+ tasks []types.Task
diff --git a/ac7d86e/internal/ui/queries.go b/ac7d86e/internal/ui/queries.go
deleted file mode 100644
index 6fa7bbd..0000000
--- a/ac7d86e/internal/ui/queries.go
+++ /dev/null
@@ -1,17 +0,0 @@
-func insertNewTLInDB(db *sql.DB, taskId int, beginTs time.Time) error
-func updateTLBeginTSInDB(db *sql.DB, beginTs time.Time) error
-func deleteActiveTLInDB(db *sql.DB) error
-func updateActiveTLInDB(db *sql.DB, taskLogId int, taskId int, beginTs, endTs time.Time, secsSpent int, comment string) error
-func insertManualTLInDB(db *sql.DB, taskId int, beginTs time.Time, endTs time.Time, comment string) error
-func fetchActiveTaskFromDB(db *sql.DB) (activeTaskDetails, error)
-func insertTaskInDB(db *sql.DB, summary string) error
-func updateTaskInDB(db *sql.DB, id int, summary string) error
-func updateTaskActiveStatusInDB(db *sql.DB, id int, active bool) error
-func updateTaskDataFromDB(db *sql.DB, t *task) error
-func fetchTasksFromDB(db *sql.DB, active bool, limit int) ([]task, error)
-func fetchTLEntriesFromDB(db *sql.DB, desc bool, limit int) ([]taskLogEntry, error)
-func fetchTLEntriesBetweenTSFromDB(db *sql.DB, beginTs, endTs time.Time, limit int) ([]taskLogEntry, error)
-func fetchStatsFromDB(db *sql.DB, limit int) ([]taskReportEntry, error)
-func fetchStatsBetweenTSFromDB(db *sql.DB, beginTs, endTs time.Time, limit int) ([]taskReportEntry, error)
-func fetchReportBetweenTSFromDB(db *sql.DB, beginTs, endTs time.Time, limit int) ([]taskReportEntry, error)
-func deleteEntry(db *sql.DB, entry *taskLogEntry) error
diff --git a/ac7d86e/internal/ui/render_helpers.go b/ac7d86e/internal/ui/render_helpers.go
deleted file mode 100644
index cbd4992..0000000
--- a/ac7d86e/internal/ui/render_helpers.go
+++ /dev/null
@@ -1,4 +0,0 @@
-func (t *task) updateTitle()
-func (t *task) updateDesc()
-func (tl *taskLogEntry) updateTitle()
-func (tl *taskLogEntry) updateDesc()
diff --git a/ac7d86e/internal/ui/report.go b/53b6ad1/internal/ui/report.go
index c6602d1..932dc90 100644
--- a/ac7d86e/internal/ui/report.go
+++ b/53b6ad1/internal/ui/report.go
@@ -1 +1 @@
-func RenderReport(db *sql.DB, writer io.Writer, plain bool, period string, agg bool, interactive bool)
+func RenderReport(db *sql.DB, writer io.Writer, plain bool, period string, agg bool, interactive bool) error
diff --git a/ac7d86e/internal/ui/stats.go b/53b6ad1/internal/ui/stats.go
index b52b202..8b61adb 100644
--- a/ac7d86e/internal/ui/stats.go
+++ b/53b6ad1/internal/ui/stats.go
@@ -1,2 +1,2 @@
-func RenderStats(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool)
-func renderStats(db *sql.DB, period string, start, end time.Time, plain bool) (string, error)
+func RenderStats(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool) error
+func getStats(db *sql.DB, period string, start, end time.Time, plain bool) (string, error)
diff --git a/ac7d86e/internal/ui/types.go b/ac7d86e/internal/ui/types.go
deleted file mode 100644
index 6d03ad8..0000000
--- a/ac7d86e/internal/ui/types.go
+++ /dev/null
@@ -1,39 +0,0 @@
-type task struct {
- id int
- summary string
- createdAt time.Time
- updatedAt time.Time
- trackingActive bool
- secsSpent int
- active bool
- title string
- desc string
-}
-type taskLogEntry struct {
- id int
- taskId int
- taskSummary string
- beginTs time.Time
- endTs time.Time
- secsSpent int
- comment string
- title string
- desc string
-}
-type activeTaskDetails struct {
- taskId int
- taskSummary string
- lastLogEntryBeginTs time.Time
-}
-type taskReportEntry struct {
- taskId int
- taskSummary string
- numEntries int
- secsSpent int
-}
-func (t task) Title() string
-func (t task) Description() string
-func (t task) FilterValue() string
-func (e taskLogEntry) Title() string
-func (e taskLogEntry) Description() string
-func (e taskLogEntry) FilterValue() string
diff --git a/ac7d86e/internal/ui/ui.go b/53b6ad1/internal/ui/ui.go
index d9cad1a..670ef15 100644
--- a/ac7d86e/internal/ui/ui.go
+++ b/53b6ad1/internal/ui/ui.go
@@ -1 +1 @@
-func RenderUI(db *sql.DB)
+func RenderUI(db *sql.DB) error
diff --git a/ac7d86e/internal/ui/update.go b/53b6ad1/internal/ui/update.go
index 76cbabe..9047a01 100644
--- a/ac7d86e/internal/ui/update.go
+++ b/53b6ad1/internal/ui/update.go
@@ -1 +1 @@
-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd)
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)
@@ -3 +3 @@ func (m recordsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd)
-func (m model) shiftTime(direction timeShiftDirection, duration timeShiftDuration) error
+func (m Model) shiftTime(direction types.TimeShiftDirection, duration types.TimeShiftDuration) error
diff --git a/ac7d86e/internal/ui/utils.go b/53b6ad1/internal/ui/utils.go
index 632ba54..b5a7e92 100644
--- a/ac7d86e/internal/ui/utils.go
+++ b/53b6ad1/internal/ui/utils.go
@@ -6,3 +5,0 @@ type reportStyles struct {
-func RightPadTrim(s string, length int, dots bool) string
-func Trim(s string, length int) string
-func humanizeDuration(durationInSecs int) string
diff --git a/ac7d86e/internal/ui/view.go b/53b6ad1/internal/ui/view.go
index df21133..8adf0fd 100644
--- a/ac7d86e/internal/ui/view.go
+++ b/53b6ad1/internal/ui/view.go
@@ -1 +1 @@
-func (m model) View() string
+func (m Model) View() string
diff --git a/53b6ad1/internal/utils/utils.go b/53b6ad1/internal/utils/utils.go
new file mode 100644
index 0000000..ea99449
--- /dev/null
+++ b/53b6ad1/internal/utils/utils.go
@@ -0,0 +1,2 @@
+func RightPadTrim(s string, length int, dots bool) string
+func Trim(s string, length int) string
As seen above, the "distilled" version only shows changes in function signatures, and type definitions, which makes maks reviewing large-scale refactors easier.
name: dstlled-diff
on:
pull_request:
jobs:
dstlled-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: get-dstlled-diff
uses: dhth/dstlled-diff-action@v0.1.0
with:
starting-commit: ${{ github.event.pull_request.base.sha }}
ending-commit: ${{ github.event.pull_request.head.sha }}
post-comment-on-pr: 'true'
The "distilled" diff will be available as an output of the action, via
steps.get-dstlled-diff.outputs.diff
.
Following inputs can be used as step.with
keys:
Name | Type | Default | Description |
---|---|---|---|
starting-commit |
String | Starting commit for the git revision range | |
ending-commit |
String | Ending commit for the git revision range | |
pattern |
String | * |
Pattern to run dstlled-diff on |
directory |
String | . |
Working directory (below repository root) |
post-comment-on-pr |
Bool | false |
Post comment containing dstlled-diff to corresponding pull request |
save-diff-to-file |
Bool | false |
Save diff to a local file called diff.patch |
The output of dstlled-diff
can be rendered in a web view (see it running
here), as seen in the image below. Code for this can be found
here.