{"id":15007976,"url":"https://github.com/dhth/dstlled-diff-action","last_synced_at":"2026-01-07T06:46:53.521Z","repository":{"id":257437829,"uuid":"857738594","full_name":"dhth/dstlled-diff-action","owner":"dhth","description":"Get a \"distilled\" version of a pull request's diff","archived":false,"fork":false,"pushed_at":"2024-09-20T11:48:14.000Z","size":101,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-01-18T19:59:55.676Z","etag":null,"topics":["code-review","code-reviews","git","github","pull-request","pull-requests"],"latest_commit_sha":null,"homepage":"https://dhth.github.io/dstlled-diff-action/","language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/dhth.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2024-09-15T13:37:43.000Z","updated_at":"2024-11-21T11:46:12.000Z","dependencies_parsed_at":"2024-10-12T08:20:35.473Z","dependency_job_id":"d55f502d-db19-4801-ac0b-dee0c33ac0fc","html_url":"https://github.com/dhth/dstlled-diff-action","commit_stats":null,"previous_names":["dhth/dstlled-diff-action"],"tags_count":5,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dhth%2Fdstlled-diff-action","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dhth%2Fdstlled-diff-action/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dhth%2Fdstlled-diff-action/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dhth%2Fdstlled-diff-action/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/dhth","download_url":"https://codeload.github.com/dhth/dstlled-diff-action/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":243180642,"owners_count":20249325,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["code-review","code-reviews","git","github","pull-request","pull-requests"],"created_at":"2024-09-24T19:14:42.193Z","updated_at":"2026-01-07T06:46:53.479Z","avatar_url":"https://github.com/dhth.png","language":"Shell","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003ch1 align=\"center\"\u003edstlled-diff\u003c/h1\u003e\n  \u003cp align=\"center\"\u003e\n    \u003ca href=\"https://github.com/dhth/dstlled-diff-action/releases/latest\"\u003e\u003cimg alt=\"GitHub release\" src=\"https://img.shields.io/github/release/dhth/dstlled-diff-action.svg?logo=github\u0026style=flat-square\"\u003e\u003c/a\u003e\n    \u003ca href=\"https://github.com/marketplace/actions/dstlled-diff\"\u003e\u003cimg alt=\"GitHub marketplace\" src=\"https://img.shields.io/badge/marketplace-dstlled--diff--action-blue?logo=github\u0026style=flat-square\"\u003e\u003c/a\u003e\n    \u003ca href=\"https://dhth.github.io/dstlled-diff-action\"\u003e\u003cimg alt=\"GitHub marketplace\" src=\"https://img.shields.io/website?url=https%3A%2F%2Fdhth.github.io%2Fdstlled-diff-action\u0026style=flat-square\u0026label=web-demo\"\u003e\u003c/a\u003e\n  \u003c/p\u003e\n\u003c/p\u003e\n\n✨ Overview\n---\n\n`dstlled-diff` (short for \"distilled-diff\") is a GitHub action that processes a\nspecific git revision range and generates a diff that only includes changes in\nthe signatures of \"code constructs\" *(functions, methods, classes, traits,\ninterfaces, objects, type aliases, enums, etc.)*. It is powered by [dstll][1].\n\nThe main goal of `dstlled-diff` is to simplify the review of large structural\ncode changes by removing diff components that do not alter signatures.\n\n![Usage](https://tools.dhruvs.space/images/dstlled-diff/dstlled-diff-1.png)\n\n📜 Languages supported\n---\n\n- ![go](https://img.shields.io/badge/go-grey?logo=go)\n- ![python](https://img.shields.io/badge/python-grey?logo=python)\n- ![rust](https://img.shields.io/badge/rust-grey?logo=rust)\n- ![scala 2](https://img.shields.io/badge/scala-grey?logo=scala)\n- more to come\n\nΔ Difference to regular git diff\n---\n\n👉 Consider this *(fairly long)* git diff:\n\n\u003cdetails\u003e\u003csummary\u003e expand \u003c/summary\u003e\n\n```diff\ndiff --git a/cmd/db_migrations_test.go b/cmd/db_migrations_test.go\ndeleted file mode 100644\nindex ba16c00..0000000\n--- a/cmd/db_migrations_test.go\n+++ /dev/null\n@@ -1,16 +0,0 @@\n-package cmd\n-\n-import (\n-\t\"testing\"\n-\n-\t\"github.com/stretchr/testify/assert\"\n-)\n-\n-func TestMigrationsAreSetupCorrectly(t *testing.T) {\n-\tmigrations := getMigrations()\n-\tfor i := 2; i \u003c= latestDBVersion; i++ {\n-\t\tm, ok := migrations[i]\n-\t\tassert.True(t, ok)\n-\t\tassert.NotEmpty(t, m)\n-\t}\n-}\ndiff --git a/cmd/root.go b/cmd/root.go\nindex c1916bc..b3f47d4 100644\n--- a/cmd/root.go\n+++ b/cmd/root.go\n@@ -1,310 +1,386 @@\n package cmd\n \n import (\n \t\"bufio\"\n \t\"database/sql\"\n \t\"errors\"\n \t\"fmt\"\n \t\"io/fs\"\n \t\"math/rand\"\n \t\"os\"\n-\t\"os/user\"\n+\t\"path/filepath\"\n \t\"strings\"\n \n+\tpers \"github.com/dhth/hours/internal/persistence\"\n \t\"github.com/dhth/hours/internal/ui\"\n \t\"github.com/spf13/cobra\"\n )\n \n const (\n-\tauthor        = \"@dhth\"\n-\trepoIssuesUrl = \"https://github.com/dhth/hours/issues\"\n+\tdefaultDBName     = \"hours.db\"\n+\tauthor            = \"@dhth\"\n+\trepoIssuesURL     = \"https://github.com/dhth/hours/issues\"\n+\tnumDaysThreshold  = 30\n+\tnumTasksThreshold = 20\n )\n \n var (\n-\tdbPath              string\n-\tdb                  *sql.DB\n-\treportAgg           bool\n-\trecordsInteractive  bool\n-\trecordsOutputPlain  bool\n-\tactiveTemplate      string\n-\tgenNumDays          uint8\n-\tgenNumTasks         uint8\n-\tgenSkipConfirmation bool\n+\terrCouldntGetHomeDir        = errors.New(\"couldn't get home directory\")\n+\terrDBFileExtIncorrect       = errors.New(\"db file needs to end with .db\")\n+\terrCouldntCreateDBDirectory = errors.New(\"couldn't create directory for database\")\n+\terrCouldntCreateDB          = errors.New(\"couldn't create database\")\n+\terrCouldntInitializeDB      = errors.New(\"couldn't initialize database\")\n+\terrCouldntOpenDB            = errors.New(\"couldn't open database\")\n+\terrCouldntGenerateData      = errors.New(\"couldn't generate dummy data\")\n+\terrNumDaysExceedsThreshold  = errors.New(\"number of days exceeds threshold\")\n+\terrNumTasksExceedsThreshold = errors.New(\"number of tasks exceeds threshold\")\n+\terrCouldntReadInput         = errors.New(\"couldn't read input\")\n+\terrIncorrectCodeEntered     = errors.New(\"incorrect code entered\")\n+\n+\tmsgReportIssue = fmt.Sprintf(\"This isn't supposed to happen; let %s know about this error via \\n%s.\", author, repoIssuesURL)\n )\n \n-func die(msg string, args ...any) {\n-\tfmt.Fprintf(os.Stderr, msg+\"\\n\", args...)\n-\tos.Exit(1)\n-}\n-\n-func setupDB() {\n-\tif dbPath == \"\" {\n-\t\tdie(\"dbpath cannot be empty\")\n+func Execute() error {\n+\trootCmd, err := NewRootCommand()\n+\tif err != nil {\n+\t\tfmt.Fprintf(os.Stderr, \"Error: %s\\n\", err)\n+\t\tif errors.Is(err, errCouldntGetHomeDir) {\n+\t\t\tfmt.Printf(\"\\n%s\\n\", msgReportIssue)\n+\t\t}\n+\t\treturn err\n \t}\n \n-\tdbPathFull := expandTilde(dbPath)\n+\terr = rootCmd.Execute()\n+\tif errors.Is(err, errCouldntGenerateData) {\n+\t\tfmt.Printf(\"\\n%s\\n\", msgReportIssue)\n+\t}\n+\treturn err\n+}\n \n+func setupDB(dbPathFull string) (*sql.DB, error) {\n+\tvar db *sql.DB\n \tvar err error\n \n \t_, err = os.Stat(dbPathFull)\n \tif errors.Is(err, fs.ErrNotExist) {\n-\t\tdb, err = getDB(dbPathFull)\n-\t\tif err != nil {\n-\t\t\tdie(`Couldn't create hours' local database. This is a fatal error;\n-let %s know about this via %s.\n \n-Error: %s`,\n-\t\t\t\tauthor,\n-\t\t\t\trepoIssuesUrl,\n-\t\t\t\terr)\n+\t\tdir := filepath.Dir(dbPathFull)\n+\t\terr = os.MkdirAll(dir, 0o755)\n+\t\tif err != nil {\n+\t\t\treturn nil, fmt.Errorf(\"%w: %s\", errCouldntCreateDBDirectory, err.Error())\n \t\t}\n \n-\t\terr = initDB(db)\n+\t\tdb, err = pers.GetDB(dbPathFull)\n \t\tif err != nil {\n-\t\t\tdie(`Couldn't create hours' local database. This is a fatal error;\n-let %s know about this via %s.\n-\n-Error: %s`,\n-\t\t\t\tauthor,\n-\t\t\t\trepoIssuesUrl,\n-\t\t\t\terr)\n+\t\t\treturn nil, fmt.Errorf(\"%w: %s\", errCouldntCreateDB, err.Error())\n+\t\t}\n+\n+\t\terr = pers.InitDB(db)\n+\t\tif err != nil {\n+\t\t\treturn nil, fmt.Errorf(\"%w: %s\", errCouldntInitializeDB, err.Error())\n+\t\t}\n+\t\terr = pers.UpgradeDB(db, 1)\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n \t\t}\n-\t\tupgradeDB(db, 1)\n \t} else {\n-\t\tdb, err = getDB(dbPathFull)\n+\t\tdb, err = pers.GetDB(dbPathFull)\n \t\tif err != nil {\n-\t\t\tdie(`Couldn't open hours' local database. This is a fatal error;\n-let %s know about this via %s.\n-\n-Error: %s`,\n-\t\t\t\tauthor,\n-\t\t\t\trepoIssuesUrl,\n-\t\t\t\terr)\n+\t\t\treturn nil, fmt.Errorf(\"%w: %s\", errCouldntOpenDB, err.Error())\n+\t\t}\n+\t\terr = pers.UpgradeDBIfNeeded(db)\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n \t\t}\n-\t\tupgradeDBIfNeeded(db)\n \t}\n+\n+\treturn db, nil\n }\n \n-var rootCmd = \u0026cobra.Command{\n-\tUse:   \"hours\",\n-\tShort: \"\\\"hours\\\" is a no-frills time tracking toolkit for the command line\",\n-\tLong: `\"hours\" is a no-frills time tracking toolkit for the command line.\n+func NewRootCommand() (*cobra.Command, error) {\n+\tvar (\n+\t\tuserHomeDir         string\n+\t\tdbPath              string\n+\t\tdbPathFull          string\n+\t\tdb                  *sql.DB\n+\t\treportAgg           bool\n+\t\trecordsInteractive  bool\n+\t\trecordsOutputPlain  bool\n+\t\tactiveTemplate      string\n+\t\tgenNumDays          uint8\n+\t\tgenNumTasks         uint8\n+\t\tgenSkipConfirmation bool\n+\t)\n+\n+\trootCmd := \u0026cobra.Command{\n+\t\tUse:   \"hours\",\n+\t\tShort: \"\\\"hours\\\" is a no-frills time tracking toolkit for the command line\",\n+\t\tLong: `\"hours\" is a no-frills time tracking toolkit for the command line.\n \n You can use \"hours\" to track time on your tasks, or view logs, reports, and\n summary statistics for your tracked time.\n `,\n-\tPersistentPreRun: func(cmd *cobra.Command, args []string) {\n-\t\tif cmd.CalledAs() == \"gen\" {\n-\t\t\treturn\n-\t\t}\n-\t\tsetupDB()\n-\t},\n-\tRun: func(cmd *cobra.Command, args []string) {\n-\t\tui.RenderUI(db)\n-\t},\n-}\n+\t\tSilenceUsage: true,\n+\t\tPersistentPreRunE: func(cmd *cobra.Command, _ []string) error {\n+\t\t\tif cmd.CalledAs() == \"updates\" {\n+\t\t\t\treturn nil\n+\t\t\t}\n \n-var generateCmd = \u0026cobra.Command{\n-\tUse:   \"gen\",\n-\tShort: \"Generate dummy log entries (helpful for beginners)\",\n-\tLong: `Generate dummy log entries.\n+\t\t\tdbPathFull = expandTilde(dbPath, userHomeDir)\n+\t\t\tif filepath.Ext(dbPathFull) != \".db\" {\n+\t\t\t\treturn errDBFileExtIncorrect\n+\t\t\t}\n+\n+\t\t\tvar err error\n+\t\t\tdb, err = setupDB(dbPathFull)\n+\t\t\tswitch {\n+\t\t\tcase errors.Is(err, errCouldntCreateDB):\n+\t\t\t\tfmt.Fprintf(os.Stderr, `Couldn't create omm's local database.\n+%s\n+\n+`, msgReportIssue)\n+\t\t\tcase errors.Is(err, errCouldntInitializeDB):\n+\t\t\t\tfmt.Fprintf(os.Stderr, `Couldn't initialise omm's local database.\n+%s\n+\n+`, msgReportIssue)\n+\t\t\t\t// cleanup\n+\t\t\t\tcleanupErr := os.Remove(dbPathFull)\n+\t\t\t\tif cleanupErr != nil {\n+\t\t\t\t\tfmt.Fprintf(os.Stderr, `Failed to remove omm's database file as well (at %s). Remove it manually.\n+Clean up error: %s\n+\n+`, dbPathFull, cleanupErr.Error())\n+\t\t\t\t}\n+\t\t\tcase errors.Is(err, errCouldntOpenDB):\n+\t\t\t\tfmt.Fprintf(os.Stderr, `Couldn't open omm's local database.\n+%s\n+\n+`, msgReportIssue)\n+\t\t\tcase errors.Is(err, pers.ErrCouldntFetchDBVersion):\n+\t\t\t\tfmt.Fprintf(os.Stderr, `Couldn't get omm's latest database version.\n+%s\n+\n+`, msgReportIssue)\n+\t\t\tcase errors.Is(err, pers.ErrDBDowngraded):\n+\t\t\t\tfmt.Fprintf(os.Stderr, `Looks like you downgraded omm. You should either delete omm's database file (you\n+will lose data by doing that), or upgrade omm to the latest version.\n+\n+`)\n+\t\t\tcase errors.Is(err, pers.ErrDBMigrationFailed):\n+\t\t\t\tfmt.Fprintf(os.Stderr, `Something went wrong migrating omm's database.\n+\n+You can try running omm by passing it a custom database file path (using\n+--db-path; this will create a new database) to see if that fixes things. If that\n+works, you can either delete the previous database, or keep using this new\n+database (both are not ideal).\n+\n+%s\n+Sorry for breaking the upgrade step!\n+\n+---\n+\n+`, msgReportIssue)\n+\t\t\t}\n+\n+\t\t\tif err != nil {\n+\t\t\t\treturn err\n+\t\t\t}\n+\n+\t\t\treturn nil\n+\t\t},\n+\t\tRunE: func(_ *cobra.Command, _ []string) error {\n+\t\t\treturn ui.RenderUI(db)\n+\t\t},\n+\t}\n+\n+\tgenerateCmd := \u0026cobra.Command{\n+\t\tUse:   \"gen\",\n+\t\tShort: \"Generate dummy log entries (helpful for beginners)\",\n+\t\tLong: `Generate dummy log entries.\n This is intended for new users of 'hours' so they can get a sense of its\n capabilities without actually tracking any time. It's recommended to always use\n this with a --dbpath/-d flag that points to a throwaway database.\n `,\n-\tRun: func(cmd *cobra.Command, args []string) {\n-\t\tif genNumDays \u003e 30 {\n-\t\t\tdie(\"Maximum value for number of days is 30\")\n-\t\t}\n-\t\tif genNumTasks \u003e 20 {\n-\t\t\tdie(\"Maximum value for number of days is 20\")\n-\t\t}\n+\t\tRunE: func(_ *cobra.Command, _ []string) error {\n+\t\t\tif genNumDays \u003e numDaysThreshold {\n+\t\t\t\treturn fmt.Errorf(\"%w (%d)\", errNumDaysExceedsThreshold, numDaysThreshold)\n+\t\t\t}\n+\t\t\tif genNumTasks \u003e numTasksThreshold {\n+\t\t\t\treturn fmt.Errorf(\"%w (%d)\", errNumTasksExceedsThreshold, numTasksThreshold)\n+\t\t\t}\n \n-\t\tdbPathFull := expandTilde(dbPath)\n-\n-\t\t_, statErr := os.Stat(dbPathFull)\n-\t\tif statErr == nil {\n-\t\t\tdie(`A file already exists at %s. Either delete it, or use a different path.\n-\n-Tip: 'gen' should always be used on a throwaway database file.`, dbPathFull)\n-\t\t}\n-\n-\t\tif !genSkipConfirmation {\n-\t\t\tfmt.Print(ui.WarningStyle.Render(`\n+\t\t\tif !genSkipConfirmation {\n+\t\t\t\tfmt.Print(ui.WarningStyle.Render(`\n WARNING: You shouldn't run 'gen' on hours' actively used database as it'll\n-create dummy entries in it. You can run it out on a throwaway database by\n-passing a path for it via --dbpath/-d (use it for all further invocations of\n-'hours' as well).\n+create dummy entries in it. You can run it on a throwaway database by passing a\n+path for it via --dbpath/-d (use it for all further invocations of 'hours' as\n+well).\n `))\n-\t\t\tfmt.Print(`\n+\t\t\t\tfmt.Print(`\n The 'gen' subcommand is intended for new users of 'hours' so they can get a\n sense of its capabilities without actually tracking any time.\n \n ---\n \n `)\n-\t\t\tconfirm := getConfirmation()\n-\t\t\tif !confirm {\n-\t\t\t\tfmt.Printf(\"\\nIncorrect code; exiting\\n\")\n-\t\t\t\tos.Exit(1)\n+\t\t\t\tconfirm, err := getConfirmation()\n+\t\t\t\tif err != nil {\n+\t\t\t\t\treturn err\n+\t\t\t\t}\n+\t\t\t\tif !confirm {\n+\t\t\t\t\treturn fmt.Errorf(\"%w\", errIncorrectCodeEntered)\n+\t\t\t\t}\n \t\t\t}\n-\t\t}\n \n-\t\tsetupDB()\n-\t\tgenErr := ui.GenerateData(db, genNumDays, genNumTasks)\n-\t\tif genErr != nil {\n-\t\t\tdie(`Something went wrong generating dummy data.\n-let %s know about this via %s.\n-\n-Error: %s`, author, repoIssuesUrl, genErr)\n-\t\t}\n-\t\tfmt.Printf(`\n+\t\t\tgenErr := ui.GenerateData(db, genNumDays, genNumTasks)\n+\t\t\tif genErr != nil {\n+\t\t\t\treturn fmt.Errorf(\"%w: %s\", errCouldntGenerateData, genErr.Error())\n+\t\t\t}\n+\t\t\tfmt.Printf(`\n Successfully generated dummy data in the database file: %s\n \n If this is not the default database file path, use --dbpath/-d with 'hours' when\n you want to access the dummy data.\n \n Go ahead and try the following!\n \n hours --dbpath=%s\n hours --dbpath=%s report week -i\n hours --dbpath=%s log today -i\n hours --dbpath=%s stats today -i\n `, dbPath, dbPath, dbPath, dbPath, dbPath)\n-\t},\n-}\n+\t\t\treturn nil\n+\t\t},\n+\t}\n \n-var reportCmd = \u0026cobra.Command{\n-\tUse:   \"report\",\n-\tShort: \"Output a report based on task log entries\",\n-\tLong: `Output a report based on task log entries.\n+\treportCmd := \u0026cobra.Command{\n+\t\tUse:   \"report\",\n+\t\tShort: \"Output a report based on task log entries\",\n+\t\tLong: `Output a report based on task log entries.\n \n Reports show time spent on tasks per day in the time period you specify. These\n can also be aggregated (using -a) to consolidate all task entries and show the\n cumulative time spent on each task per day.\n \n Accepts an argument, which can be one of the following:\n \n   today:     for today's report\n   yest:      for yesterday's report\n   3d:        for a report on the last 3 days (default)\n   week:      for a report on the current week\n   date:      for a report for a specific date (eg. \"2024/06/08\")\n   range:     for a report for a date range (eg. \"2024/06/08...2024/06/12\")\n \n Note: If a task log continues past midnight in your local timezone, it\n will be reported on the day it ends.\n `,\n-\tArgs: cobra.MaximumNArgs(1),\n-\tRun: func(cmd *cobra.Command, args []string) {\n-\t\tvar period string\n-\t\tif len(args) == 0 {\n-\t\t\tperiod = \"3d\"\n-\t\t} else {\n-\t\t\tperiod = args[0]\n-\t\t}\n+\t\tArgs: cobra.MaximumNArgs(1),\n+\t\tRunE: func(_ *cobra.Command, args []string) error {\n+\t\t\tvar period string\n+\t\t\tif len(args) == 0 {\n+\t\t\t\tperiod = \"3d\"\n+\t\t\t} else {\n+\t\t\t\tperiod = args[0]\n+\t\t\t}\n \n-\t\tui.RenderReport(db, os.Stdout, recordsOutputPlain, period, reportAgg, recordsInteractive)\n-\t},\n-}\n+\t\t\treturn ui.RenderReport(db, os.Stdout, recordsOutputPlain, period, reportAgg, recordsInteractive)\n+\t\t},\n+\t}\n \n-var logCmd = \u0026cobra.Command{\n-\tUse:   \"log\",\n-\tShort: \"Output task log entries\",\n-\tLong: `Output task log entries.\n+\tlogCmd := \u0026cobra.Command{\n+\t\tUse:   \"log\",\n+\t\tShort: \"Output task log entries\",\n+\t\tLong: `Output task log entries.\n \n Accepts an argument, which can be one of the following:\n \n   today:     for log entries from today (default)\n   yest:      for log entries from yesterday\n   3d:        for log entries from the last 3 days\n   week:      for log entries from the current week\n   date:      for log entries from a specific date (eg. \"2024/06/08\")\n   range:     for log entries from a specific date range (eg. \"2024/06/08...2024/06/12\")\n \n Note: If a task log continues past midnight in your local timezone, it'll\n appear in the log for the day it ends.\n `,\n-\tArgs: cobra.MaximumNArgs(1),\n-\tRun: func(cmd *cobra.Command, args []string) {\n-\t\tvar period string\n-\t\tif len(args) == 0 {\n-\t\t\tperiod = \"today\"\n-\t\t} else {\n-\t\t\tperiod = args[0]\n-\t\t}\n+\t\tArgs: cobra.MaximumNArgs(1),\n+\t\tRunE: func(_ *cobra.Command, args []string) error {\n+\t\t\tvar period string\n+\t\t\tif len(args) == 0 {\n+\t\t\t\tperiod = \"today\"\n+\t\t\t} else {\n+\t\t\t\tperiod = args[0]\n+\t\t\t}\n \n-\t\tui.RenderTaskLog(db, os.Stdout, recordsOutputPlain, period, recordsInteractive)\n-\t},\n-}\n+\t\t\treturn ui.RenderTaskLog(db, os.Stdout, recordsOutputPlain, period, recordsInteractive)\n+\t\t},\n+\t}\n \n-var statsCmd = \u0026cobra.Command{\n-\tUse:   \"stats\",\n-\tShort: \"Output statistics for tracked time\",\n-\tLong: `Output statistics for tracked time.\n+\tstatsCmd := \u0026cobra.Command{\n+\t\tUse:   \"stats\",\n+\t\tShort: \"Output statistics for tracked time\",\n+\t\tLong: `Output statistics for tracked time.\n \n Accepts an argument, which can be one of the following:\n \n   today:     show stats for today\n   yest:      show stats for yesterday\n   3d:        show stats for the last 3 days (default)\n   week:      show stats for the current week\n   date:      show stats for a specific date (eg. \"2024/06/08\")\n   range:     show stats for a specific date range (eg. \"2024/06/08...2024/06/12\")\n   all:       show stats for all log entries\n \n Note: If a task log continues past midnight in your local timezone, it'll\n be considered in the stats for the day it ends.\n `,\n-\tArgs: cobra.MaximumNArgs(1),\n-\tRun: func(cmd *cobra.Command, args []string) {\n-\t\tvar period string\n-\t\tif len(args) == 0 {\n-\t\t\tperiod = \"3d\"\n-\t\t} else {\n-\t\t\tperiod = args[0]\n-\t\t}\n+\t\tArgs: cobra.MaximumNArgs(1),\n+\t\tRunE: func(_ *cobra.Command, args []string) error {\n+\t\t\tvar period string\n+\t\t\tif len(args) == 0 {\n+\t\t\t\tperiod = \"3d\"\n+\t\t\t} else {\n+\t\t\t\tperiod = args[0]\n+\t\t\t}\n \n-\t\tui.RenderStats(db, os.Stdout, recordsOutputPlain, period, recordsInteractive)\n-\t},\n-}\n+\t\t\treturn ui.RenderStats(db, os.Stdout, recordsOutputPlain, period, recordsInteractive)\n+\t\t},\n+\t}\n \n-var activeCmd = \u0026cobra.Command{\n-\tUse:   \"active\",\n-\tShort: \"Show the task being actively tracked by \\\"hours\\\"\",\n-\tLong: `Show the task being actively tracked by \"hours\".\n+\tactiveCmd := \u0026cobra.Command{\n+\t\tUse:   \"active\",\n+\t\tShort: \"Show the task being actively tracked by \\\"hours\\\"\",\n+\t\tLong: `Show the task being actively tracked by \"hours\".\n \n You can pass in a template using the --template/-t flag, which supports the\n following placeholders:\n \n   {{task}}:  for the task summary\n   {{time}}:  for the time spent so far on the active log entry\n \n eg. hours active -t ' {{task}} ({{time}}) '\n `,\n-\tRun: func(cmd *cobra.Command, args []string) {\n-\t\tui.ShowActiveTask(db, os.Stdout, activeTemplate)\n-\t},\n-}\n-\n-func init() {\n-\tcurrentUser, err := user.Current()\n-\tif err != nil {\n-\t\tdie(`Couldn't get your home directory. This is a fatal error;\n-use --dbpath to specify database path manually\n-let %s know about this via %s.\n-\n-Error: %s`, author, repoIssuesUrl, err)\n+\t\tRunE: func(_ *cobra.Command, _ []string) error {\n+\t\t\treturn ui.ShowActiveTask(db, os.Stdout, activeTemplate)\n+\t\t},\n \t}\n \n-\tdefaultDBPath := fmt.Sprintf(\"%s/hours.db\", currentUser.HomeDir)\n+\tvar err error\n+\tuserHomeDir, err = os.UserHomeDir()\n+\tif err != nil {\n+\t\treturn nil, fmt.Errorf(\"%w: %s\", errCouldntGetHomeDir, err.Error())\n+\t}\n+\n+\tdefaultDBPath := filepath.Join(userHomeDir, defaultDBName)\n \trootCmd.PersistentFlags().StringVarP(\u0026dbPath, \"dbpath\", \"d\", defaultDBPath, \"location of hours' database file\")\n \n \tgenerateCmd.Flags().Uint8Var(\u0026genNumDays, \"num-days\", 30, \"number of days to generate fake data for\")\n \tgenerateCmd.Flags().Uint8Var(\u0026genNumTasks, \"num-tasks\", 10, \"number of tasks to generate fake data for\")\n \tgenerateCmd.Flags().BoolVarP(\u0026genSkipConfirmation, \"yes\", \"y\", false, \"to skip confirmation\")\n \n \treportCmd.Flags().BoolVarP(\u0026reportAgg, \"agg\", \"a\", false, \"whether to aggregate data by task for each day in report\")\n \treportCmd.Flags().BoolVarP(\u0026recordsInteractive, \"interactive\", \"i\", false, \"whether to view report interactively\")\n \treportCmd.Flags().BoolVarP(\u0026recordsOutputPlain, \"plain\", \"p\", false, \"whether to output report without any formatting\")\n \n@@ -316,43 +392,38 @@ Error: %s`, author, repoIssuesUrl, err)\n \n \tactiveCmd.Flags().StringVarP(\u0026activeTemplate, \"template\", \"t\", ui.ActiveTaskPlaceholder, \"string template to use for outputting active task\")\n \n \trootCmd.AddCommand(generateCmd)\n \trootCmd.AddCommand(reportCmd)\n \trootCmd.AddCommand(logCmd)\n \trootCmd.AddCommand(statsCmd)\n \trootCmd.AddCommand(activeCmd)\n \n \trootCmd.CompletionOptions.DisableDefaultCmd = true\n-}\n \n-func Execute() {\n-\terr := rootCmd.Execute()\n-\tif err != nil {\n-\t\tdie(\"Something went wrong: %s\", err)\n-\t}\n+\treturn rootCmd, nil\n }\n \n func getRandomChars(length int) string {\n \tconst charset = \"abcdefghijklmnopqrstuvwxyz\"\n \n \tvar code string\n \tfor i := 0; i \u003c length; i++ {\n \t\tcode += string(charset[rand.Intn(len(charset))])\n \t}\n \treturn code\n }\n \n-func getConfirmation() bool {\n+func getConfirmation() (bool, error) {\n \tcode := getRandomChars(2)\n \treader := bufio.NewReader(os.Stdin)\n \n \tfmt.Printf(\"Type %s to proceed: \", code)\n \n \tresponse, err := reader.ReadString('\\n')\n \tif err != nil {\n-\t\tdie(\"Something went wrong reading input: %s\", err)\n+\t\treturn false, fmt.Errorf(\"%w: %s\", errCouldntReadInput, err.Error())\n \t}\n \tresponse = strings.TrimSpace(response)\n \n-\treturn response == code\n+\treturn response == code, nil\n }\ndiff --git a/cmd/utils.go b/cmd/utils.go\nindex ea1318e..62349f7 100644\n--- a/cmd/utils.go\n+++ b/cmd/utils.go\n@@ -1,18 +1,14 @@\n package cmd\n \n import (\n-\t\"os\"\n-\t\"os/user\"\n+\t\"path/filepath\"\n \t\"strings\"\n )\n \n-func expandTilde(path string) string {\n-\tif strings.HasPrefix(path, \"~\") {\n-\t\tusr, err := user.Current()\n-\t\tif err != nil {\n-\t\t\tos.Exit(1)\n-\t\t}\n-\t\treturn strings.Replace(path, \"~\", usr.HomeDir, 1)\n+func expandTilde(path string, homeDir string) string {\n+\tpathWithoutTilde, found := strings.CutPrefix(path, \"~/\")\n+\tif !found {\n+\t\treturn path\n \t}\n-\treturn path\n+\treturn filepath.Join(homeDir, pathWithoutTilde)\n }\ndiff --git a/cmd/utils_test.go b/cmd/utils_test.go\nnew file mode 100644\nindex 0000000..9d73a0d\n--- /dev/null\n+++ b/cmd/utils_test.go\n@@ -0,0 +1,37 @@\n+package cmd\n+\n+import (\n+\t\"testing\"\n+\n+\t\"github.com/stretchr/testify/assert\"\n+)\n+\n+func TestExpandTilde(t *testing.T) {\n+\ttestCases := []struct {\n+\t\tname     string\n+\t\tpath     string\n+\t\thomeDir  string\n+\t\texpected string\n+\t}{\n+\t\t{\n+\t\t\tname:     \"a simple case\",\n+\t\t\tpath:     \"~/some/path\",\n+\t\t\thomeDir:  \"/Users/trinity\",\n+\t\t\texpected: \"/Users/trinity/some/path\",\n+\t\t},\n+\t\t{\n+\t\t\tname:     \"path with no ~\",\n+\t\t\tpath:     \"some/path\",\n+\t\t\thomeDir:  \"/Users/trinity\",\n+\t\t\texpected: \"some/path\",\n+\t\t},\n+\t}\n+\n+\tfor _, tt := range testCases {\n+\t\tt.Run(tt.name, func(t *testing.T) {\n+\t\t\tgot := expandTilde(tt.path, tt.homeDir)\n+\n+\t\t\tassert.Equal(t, tt.expected, got)\n+\t\t})\n+\t}\n+}\ndiff --git a/cmd/db.go b/internal/persistence/init.go\nsimilarity index 87%\nrename from cmd/db.go\nrename to internal/persistence/init.go\nindex bb572d5..3d71182 100644\n--- a/cmd/db.go\n+++ b/internal/persistence/init.go\n@@ -1,25 +1,18 @@\n-package cmd\n+package persistence\n \n import (\n \t\"database/sql\"\n \t\"time\"\n )\n \n-func getDB(dbpath string) (*sql.DB, error) {\n-\tdb, err := sql.Open(\"sqlite\", dbpath)\n-\tdb.SetMaxOpenConns(1)\n-\tdb.SetMaxIdleConns(1)\n-\treturn db, err\n-}\n-\n-func initDB(db *sql.DB) error {\n+func InitDB(db *sql.DB) error {\n \t// these init queries cannot be changed\n \t// once hours is released; only further migrations\n \t// can be added, which are run whenever hours\n \t// sees a difference between the values in db_versions\n \t// and latestDBVersion\n \t_, err := db.Exec(`\n CREATE TABLE IF NOT EXISTS db_versions (\n     id INTEGER PRIMARY KEY AUTOINCREMENT,\n     version INTEGER NOT NULL,\n     created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\ndiff --git a/cmd/db_migrations.go b/internal/persistence/migrations.go\nsimilarity index 57%\nrename from cmd/db_migrations.go\nrename to internal/persistence/migrations.go\nindex e36e1b7..d10f004 100644\n--- a/cmd/db_migrations.go\n+++ b/internal/persistence/migrations.go\n@@ -1,35 +1,41 @@\n-package cmd\n+package persistence\n \n import (\n \t\"database/sql\"\n+\t\"errors\"\n+\t\"fmt\"\n \t\"time\"\n )\n \n-const (\n-\tlatestDBVersion = 1 // only upgrade this after adding a migration in getMigrations\n+const latestDBVersion = 1 // only upgrade this after adding a migration in getMigrations\n+\n+var (\n+\tErrDBDowngraded          = errors.New(\"database downgraded\")\n+\tErrDBMigrationFailed     = errors.New(\"database migration failed\")\n+\tErrCouldntFetchDBVersion = errors.New(\"couldn't fetch version\")\n )\n \n type dbVersionInfo struct {\n \tid        int\n \tversion   int\n \tcreatedAt time.Time\n }\n \n func getMigrations() map[int]string {\n \tmigrations := make(map[int]string)\n \t// these migrations should not be modified once released.\n \t// that is, migrations is an append-only map.\n \n \t// migrations[2] = `\n \t// ALTER TABLE task\n-\t//     ADD COLUMN a_col INTEGER NOT NULL DEFAULT 1;\n+\t// ADD COLUMN new_col TEXT;\n \t// `\n \n \treturn migrations\n }\n \n func fetchLatestDBVersion(db *sql.DB) (dbVersionInfo, error) {\n \trow := db.QueryRow(`\n SELECT id, version, created_at\n FROM db_versions\n ORDER BY created_at DESC\n@@ -39,65 +45,54 @@ LIMIT 1;\n \tvar dbVersion dbVersionInfo\n \terr := row.Scan(\n \t\t\u0026dbVersion.id,\n \t\t\u0026dbVersion.version,\n \t\t\u0026dbVersion.createdAt,\n \t)\n \n \treturn dbVersion, err\n }\n \n-func upgradeDBIfNeeded(db *sql.DB) {\n-\tlatestVersionInDB, versionErr := fetchLatestDBVersion(db)\n-\tif versionErr != nil {\n-\t\tdie(`Couldn't get hours' latest database version. This is a fatal error; let %s\n-know about this via %s.\n-\n-Error: %s`,\n-\t\t\tauthor,\n-\t\t\trepoIssuesUrl,\n-\t\t\tversionErr)\n+func UpgradeDBIfNeeded(db *sql.DB) error {\n+\tlatestVersionInDB, err := fetchLatestDBVersion(db)\n+\tif err != nil {\n+\t\treturn fmt.Errorf(\"%w: %s\", ErrCouldntFetchDBVersion, err.Error())\n \t}\n \n \tif latestVersionInDB.version \u003e latestDBVersion {\n-\t\tdie(`Looks like you downgraded hours. You should either delete hours'\n-database file (you will lose data by doing that), or upgrade hours to\n-the latest version.`)\n+\t\treturn fmt.Errorf(\"%w; debug info: version=%d, created at=%q)\",\n+\t\t\tErrDBDowngraded,\n+\t\t\tlatestVersionInDB.version,\n+\t\t\tlatestVersionInDB.createdAt.Format(time.RFC3339),\n+\t\t)\n \t}\n \n \tif latestVersionInDB.version \u003c latestDBVersion {\n-\t\tupgradeDB(db, latestVersionInDB.version)\n+\t\terr = UpgradeDB(db, latestVersionInDB.version)\n+\t\tif err != nil {\n+\t\t\treturn err\n+\t\t}\n \t}\n+\n+\treturn nil\n }\n \n-func upgradeDB(db *sql.DB, currentVersion int) {\n+func UpgradeDB(db *sql.DB, currentVersion int) error {\n \tmigrations := getMigrations()\n \tfor i := currentVersion + 1; i \u003c= latestDBVersion; i++ {\n \t\tmigrateQuery := migrations[i]\n \t\tmigrateErr := runMigration(db, migrateQuery, i)\n \t\tif migrateErr != nil {\n-\t\t\tdie(`Something went wrong migrating hours' database to version %d. This is not\n-supposed to happen. You can try running hours by passing it a custom database\n-file path (using --dbpath; this will create a new database) to see if that fixes\n-things. If that works, you can either delete the previous database, or keep\n-using this new database (both are not ideal).\n-\n-If you can, let %s know about this error via\n-%s.\n-Sorry for breaking the upgrade step!\n-\n----\n-\n-Error: %s\n-`, i, author, repoIssuesUrl, migrateErr)\n+\t\t\treturn fmt.Errorf(\"%w (version %d): %v\", ErrDBMigrationFailed, i, migrateErr.Error())\n \t\t}\n \t}\n+\treturn nil\n }\n \n func runMigration(db *sql.DB, migrateQuery string, version int) error {\n \ttx, err := db.Begin()\n \tif err != nil {\n \t\treturn err\n \t}\n \tdefer func() {\n \t\t_ = tx.Rollback()\n \t}()\ndiff --git a/internal/persistence/migrations_test.go b/internal/persistence/migrations_test.go\nnew file mode 100644\nindex 0000000..f97ac67\n--- /dev/null\n+++ b/internal/persistence/migrations_test.go\n@@ -0,0 +1,69 @@\n+package persistence\n+\n+import (\n+\t\"database/sql\"\n+\t\"testing\"\n+\n+\t\"github.com/stretchr/testify/assert\"\n+\t_ \"modernc.org/sqlite\" // sqlite driver\n+)\n+\n+func TestMigrationsAreSetupCorrectly(t *testing.T) {\n+\t// GIVEN\n+\t// WHEN\n+\tmigrations := getMigrations()\n+\n+\t// THEN\n+\tfor i := 2; i \u003c= latestDBVersion; i++ {\n+\t\tm, ok := migrations[i]\n+\t\tif !ok {\n+\t\t\tassert.True(t, ok, \"couldn't get migration %d\", i)\n+\t\t}\n+\t\tif m == \"\" {\n+\t\t\tassert.NotEmpty(t, ok, \"migration %d is empty\", i)\n+\t\t}\n+\t}\n+}\n+\n+func TestMigrationsWork(t *testing.T) {\n+\t// GIVEN\n+\tvar testDB *sql.DB\n+\tvar err error\n+\ttestDB, err = sql.Open(\"sqlite\", \":memory:\")\n+\tif err != nil {\n+\t\tt.Fatalf(\"Couldn't open database: %s\", err.Error())\n+\t}\n+\n+\terr = InitDB(testDB)\n+\tif err != nil {\n+\t\tt.Fatalf(\"Couldn't initialize database: %s\", err.Error())\n+\t}\n+\n+\t// WHEN\n+\terr = UpgradeDB(testDB, 1)\n+\n+\t// THEN\n+\tassert.NoError(t, err)\n+}\n+\n+func TestRunMigrationFailsWhenGivenBadMigration(t *testing.T) {\n+\t// GIVEN\n+\tvar testDB *sql.DB\n+\tvar err error\n+\ttestDB, err = sql.Open(\"sqlite\", \":memory:\")\n+\tif err != nil {\n+\t\tt.Fatalf(\"Couldn't open database: %s\", err.Error())\n+\t}\n+\n+\terr = InitDB(testDB)\n+\tif err != nil {\n+\t\tt.Fatalf(\"Couldn't initialize database: %s\", err.Error())\n+\t}\n+\n+\t// WHEN\n+\tquery := \"BAD SQL CODE;\"\n+\tmigrateErr := runMigration(testDB, query, 1)\n+\n+\t// THEN\n+\tassert.Error(t, migrateErr)\n+}\ndiff --git a/internal/persistence/open.go b/internal/persistence/open.go\nnew file mode 100644\nindex 0000000..6251f9f\n--- /dev/null\n+++ b/internal/persistence/open.go\n@@ -0,0 +1,12 @@\n+package persistence\n+\n+import (\n+\t\"database/sql\"\n+)\n+\n+func GetDB(dbpath string) (*sql.DB, error) {\n+\tdb, err := sql.Open(\"sqlite\", dbpath)\n+\tdb.SetMaxOpenConns(1)\n+\tdb.SetMaxIdleConns(1)\n+\treturn db, err\n+}\ndiff --git a/internal/persistence/queries.go b/internal/persistence/queries.go\nnew file mode 100644\nindex 0000000..73f6b05\n--- /dev/null\n+++ b/internal/persistence/queries.go\n@@ -0,0 +1,615 @@\n+package persistence\n+\n+import (\n+\t\"database/sql\"\n+\t\"errors\"\n+\t\"fmt\"\n+\t\"time\"\n+\n+\t\"github.com/dhth/hours/internal/types\"\n+)\n+\n+var ErrCouldntRollBackTx = errors.New(\"couldn't roll back transaction\")\n+\n+func InsertNewTL(db *sql.DB, taskID int, beginTs time.Time) (int, error) {\n+\treturn runInTxAndReturnID(db, func(tx *sql.Tx) (int, error) {\n+\t\tstmt, err := tx.Prepare(`\n+INSERT INTO task_log (task_id, begin_ts, active)\n+VALUES (?, ?, ?);\n+`)\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\t\tdefer stmt.Close()\n+\n+\t\tres, err := stmt.Exec(taskID, beginTs.UTC(), true)\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\n+\t\tlastID, err := res.LastInsertId()\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\n+\t\treturn int(lastID), nil\n+\t})\n+}\n+\n+func UpdateTLBeginTS(db *sql.DB, beginTs time.Time) error {\n+\tstmt, err := db.Prepare(`\n+UPDATE task_log SET begin_ts=?\n+WHERE active is true;\n+`)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\tdefer stmt.Close()\n+\n+\t_, err = stmt.Exec(beginTs.UTC(), true)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\n+\treturn nil\n+}\n+\n+func DeleteActiveTL(db *sql.DB) error {\n+\tstmt, err := db.Prepare(`\n+DELETE FROM task_log\n+WHERE active=true;\n+`)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\tdefer stmt.Close()\n+\n+\t_, err = stmt.Exec()\n+\n+\treturn err\n+}\n+\n+func UpdateActiveTL(db *sql.DB, taskLogID int, taskID int, beginTs, endTs time.Time, secsSpent int, comment string) error {\n+\treturn runInTx(db, func(tx *sql.Tx) error {\n+\t\tstmt, err := tx.Prepare(`\n+UPDATE task_log\n+SET active = 0,\n+    begin_ts = ?,\n+    end_ts = ?,\n+    secs_spent = ?,\n+    comment = ?\n+WHERE id = ?\n+AND active = 1;\n+`)\n+\t\tif err != nil {\n+\t\t\treturn err\n+\t\t}\n+\t\tdefer stmt.Close()\n+\n+\t\t_, err = stmt.Exec(beginTs.UTC(), endTs.UTC(), secsSpent, comment, taskLogID)\n+\t\tif err != nil {\n+\t\t\treturn err\n+\t\t}\n+\n+\t\ttStmt, err := tx.Prepare(`\n+UPDATE task\n+SET secs_spent = secs_spent+?,\n+    updated_at = ?\n+WHERE id = ?;\n+    `)\n+\t\tif err != nil {\n+\t\t\treturn err\n+\t\t}\n+\t\tdefer tStmt.Close()\n+\n+\t\t_, err = tStmt.Exec(secsSpent, time.Now().UTC(), taskID)\n+\n+\t\treturn err\n+\t})\n+}\n+\n+func InsertManualTL(db *sql.DB, taskID int, beginTs time.Time, endTs time.Time, comment string) (int, error) {\n+\treturn runInTxAndReturnID(db, func(tx *sql.Tx) (int, error) {\n+\t\tstmt, err := tx.Prepare(`\n+INSERT INTO task_log (task_id, begin_ts, end_ts, secs_spent, comment, active)\n+VALUES (?, ?, ?, ?, ?, ?);\n+`)\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\t\tdefer stmt.Close()\n+\n+\t\tsecsSpent := int(endTs.Sub(beginTs).Seconds())\n+\n+\t\tres, err := stmt.Exec(taskID, beginTs.UTC(), endTs.UTC(), secsSpent, comment, false)\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\n+\t\tlastID, err := res.LastInsertId()\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\n+\t\ttStmt, err := tx.Prepare(`\n+UPDATE task\n+SET secs_spent = secs_spent+?,\n+    updated_at = ?\n+WHERE id = ?;\n+    `)\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\t\tdefer tStmt.Close()\n+\n+\t\t_, err = tStmt.Exec(secsSpent, time.Now().UTC(), taskID)\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\n+\t\treturn int(lastID), nil\n+\t})\n+}\n+\n+func FetchActiveTask(db *sql.DB) (types.ActiveTaskDetails, error) {\n+\trow := db.QueryRow(`\n+SELECT t.id, t.summary, tl.begin_ts\n+FROM task_log tl left join task t on tl.task_id = t.id\n+WHERE tl.active=true;\n+`)\n+\n+\tvar activeTaskDetails types.ActiveTaskDetails\n+\terr := row.Scan(\n+\t\t\u0026activeTaskDetails.TaskID,\n+\t\t\u0026activeTaskDetails.TaskSummary,\n+\t\t\u0026activeTaskDetails.LastLogEntryBeginTS,\n+\t)\n+\tif errors.Is(err, sql.ErrNoRows) {\n+\t\tactiveTaskDetails.TaskID = -1\n+\t\treturn activeTaskDetails, nil\n+\t} else if err != nil {\n+\t\treturn activeTaskDetails, err\n+\t}\n+\tactiveTaskDetails.LastLogEntryBeginTS = activeTaskDetails.LastLogEntryBeginTS.Local()\n+\treturn activeTaskDetails, nil\n+}\n+\n+func InsertTask(db *sql.DB, summary string) (int, error) {\n+\treturn runInTxAndReturnID(db, func(tx *sql.Tx) (int, error) {\n+\t\tstmt, err := tx.Prepare(`\n+INSERT into task (summary, active, created_at, updated_at)\n+VALUES (?, true, ?, ?);\n+`)\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\t\tdefer stmt.Close()\n+\n+\t\tnow := time.Now().UTC()\n+\t\tres, err := stmt.Exec(summary, now, now)\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\n+\t\tlastID, err := res.LastInsertId()\n+\t\tif err != nil {\n+\t\t\treturn -1, err\n+\t\t}\n+\n+\t\treturn int(lastID), nil\n+\t})\n+}\n+\n+func UpdateTask(db *sql.DB, id int, summary string) error {\n+\tstmt, err := db.Prepare(`\n+UPDATE task\n+SET summary = ?,\n+    updated_at = ?\n+WHERE id = ?\n+`)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\tdefer stmt.Close()\n+\n+\t_, err = stmt.Exec(summary, time.Now().UTC(), id)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\treturn nil\n+}\n+\n+func UpdateTaskActiveStatus(db *sql.DB, id int, active bool) error {\n+\tstmt, err := db.Prepare(`\n+UPDATE task\n+SET active = ?,\n+    updated_at = ?\n+WHERE id = ?\n+`)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\tdefer stmt.Close()\n+\n+\t_, err = stmt.Exec(active, time.Now().UTC(), id)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\treturn nil\n+}\n+\n+func UpdateTaskData(db *sql.DB, t *types.Task) error {\n+\trow := db.QueryRow(`\n+SELECT secs_spent, updated_at\n+FROM task\n+WHERE id=?;\n+    `, t.ID)\n+\n+\terr := row.Scan(\n+\t\t\u0026t.SecsSpent,\n+\t\t\u0026t.UpdatedAt,\n+\t)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\treturn nil\n+}\n+\n+func FetchTasks(db *sql.DB, active bool, limit int) ([]types.Task, error) {\n+\tvar tasks []types.Task\n+\n+\trows, err := db.Query(`\n+SELECT id, summary, secs_spent, created_at, updated_at, active\n+FROM task\n+WHERE active=?\n+ORDER by updated_at DESC\n+LIMIT ?;\n+    `, active, limit)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\tdefer rows.Close()\n+\n+\tfor rows.Next() {\n+\t\tvar entry types.Task\n+\t\terr = rows.Scan(\u0026entry.ID,\n+\t\t\t\u0026entry.Summary,\n+\t\t\t\u0026entry.SecsSpent,\n+\t\t\t\u0026entry.CreatedAt,\n+\t\t\t\u0026entry.UpdatedAt,\n+\t\t\t\u0026entry.Active,\n+\t\t)\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n+\t\t}\n+\t\tentry.CreatedAt = entry.CreatedAt.Local()\n+\t\tentry.UpdatedAt = entry.UpdatedAt.Local()\n+\t\ttasks = append(tasks, entry)\n+\n+\t}\n+\tif rows.Err() != nil {\n+\t\treturn nil, err\n+\t}\n+\treturn tasks, nil\n+}\n+\n+func FetchTLEntries(db *sql.DB, desc bool, limit int) ([]types.TaskLogEntry, error) {\n+\tvar logEntries []types.TaskLogEntry\n+\n+\tvar order string\n+\tif desc {\n+\t\torder = \"DESC\"\n+\t} else {\n+\t\torder = \"ASC\"\n+\t}\n+\tquery := fmt.Sprintf(`\n+SELECT tl.id, tl.task_id, t.summary, tl.begin_ts, tl.end_ts, tl.secs_spent, tl.comment\n+FROM task_log tl left join task t on tl.task_id=t.id\n+WHERE tl.active=false\n+ORDER by tl.begin_ts %s\n+LIMIT ?;\n+`, order)\n+\n+\trows, err := db.Query(query, limit)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\tdefer rows.Close()\n+\n+\tfor rows.Next() {\n+\t\tvar entry types.TaskLogEntry\n+\t\terr = rows.Scan(\u0026entry.ID,\n+\t\t\t\u0026entry.TaskID,\n+\t\t\t\u0026entry.TaskSummary,\n+\t\t\t\u0026entry.BeginTS,\n+\t\t\t\u0026entry.EndTS,\n+\t\t\t\u0026entry.SecsSpent,\n+\t\t\t\u0026entry.Comment,\n+\t\t)\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n+\t\t}\n+\t\tentry.BeginTS = entry.BeginTS.Local()\n+\t\tentry.EndTS = entry.EndTS.Local()\n+\t\tlogEntries = append(logEntries, entry)\n+\n+\t}\n+\tif rows.Err() != nil {\n+\t\treturn nil, err\n+\t}\n+\treturn logEntries, nil\n+}\n+\n+func FetchTLEntriesBetweenTS(db *sql.DB, beginTs, endTs time.Time, limit int) ([]types.TaskLogEntry, error) {\n+\tvar logEntries []types.TaskLogEntry\n+\n+\trows, err := db.Query(`\n+SELECT tl.id, tl.task_id, t.summary, tl.begin_ts, tl.end_ts, tl.secs_spent, tl.comment\n+FROM task_log tl left join task t on tl.task_id=t.id\n+WHERE tl.active=false\n+AND tl.end_ts \u003e= ?\n+AND tl.end_ts \u003c ?\n+ORDER by tl.begin_ts ASC LIMIT ?;\n+    `, beginTs.UTC(), endTs.UTC(), limit)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\tdefer rows.Close()\n+\n+\tfor rows.Next() {\n+\t\tvar entry types.TaskLogEntry\n+\t\terr = rows.Scan(\u0026entry.ID,\n+\t\t\t\u0026entry.TaskID,\n+\t\t\t\u0026entry.TaskSummary,\n+\t\t\t\u0026entry.BeginTS,\n+\t\t\t\u0026entry.EndTS,\n+\t\t\t\u0026entry.SecsSpent,\n+\t\t\t\u0026entry.Comment,\n+\t\t)\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n+\t\t}\n+\t\tentry.BeginTS = entry.BeginTS.Local()\n+\t\tentry.EndTS = entry.EndTS.Local()\n+\t\tlogEntries = append(logEntries, entry)\n+\n+\t}\n+\tif rows.Err() != nil {\n+\t\treturn nil, err\n+\t}\n+\treturn logEntries, nil\n+}\n+\n+func FetchStats(db *sql.DB, limit int) ([]types.TaskReportEntry, error) {\n+\trows, err := db.Query(`\n+SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries, t.secs_spent\n+from task_log tl\n+LEFT JOIN task t on tl.task_id = t.id\n+GROUP BY tl.task_id\n+ORDER BY t.secs_spent DESC\n+limit ?;\n+`, limit)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\tdefer rows.Close()\n+\n+\tvar tLE []types.TaskReportEntry\n+\n+\tfor rows.Next() {\n+\t\tvar entry types.TaskReportEntry\n+\t\terr = rows.Scan(\n+\t\t\t\u0026entry.TaskID,\n+\t\t\t\u0026entry.TaskSummary,\n+\t\t\t\u0026entry.NumEntries,\n+\t\t\t\u0026entry.SecsSpent,\n+\t\t)\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n+\t\t}\n+\t\ttLE = append(tLE, entry)\n+\n+\t}\n+\tif rows.Err() != nil {\n+\t\treturn nil, err\n+\t}\n+\treturn tLE, nil\n+}\n+\n+func FetchStatsBetweenTS(db *sql.DB, beginTs, endTs time.Time, limit int) ([]types.TaskReportEntry, error) {\n+\trows, err := db.Query(`\n+SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries,  SUM(tl.secs_spent) AS secs_spent\n+FROM task_log tl \n+LEFT JOIN task t ON tl.task_id = t.id\n+WHERE tl.end_ts \u003e= ? AND tl.end_ts \u003c ?\n+GROUP BY tl.task_id\n+ORDER BY secs_spent DESC\n+LIMIT ?;\n+`, beginTs.UTC(), endTs.UTC(), limit)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\tdefer rows.Close()\n+\n+\tvar tLE []types.TaskReportEntry\n+\n+\tfor rows.Next() {\n+\t\tvar entry types.TaskReportEntry\n+\t\terr = rows.Scan(\n+\t\t\t\u0026entry.TaskID,\n+\t\t\t\u0026entry.TaskSummary,\n+\t\t\t\u0026entry.NumEntries,\n+\t\t\t\u0026entry.SecsSpent,\n+\t\t)\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n+\t\t}\n+\t\ttLE = append(tLE, entry)\n+\n+\t}\n+\tif rows.Err() != nil {\n+\t\treturn nil, err\n+\t}\n+\treturn tLE, nil\n+}\n+\n+func FetchReportBetweenTS(db *sql.DB, beginTs, endTs time.Time, limit int) ([]types.TaskReportEntry, error) {\n+\trows, err := db.Query(`\n+SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries,  SUM(tl.secs_spent) AS secs_spent\n+FROM task_log tl \n+LEFT JOIN task t ON tl.task_id = t.id\n+WHERE tl.end_ts \u003e= ? AND tl.end_ts \u003c ?\n+GROUP BY tl.task_id\n+ORDER BY t.updated_at ASC\n+LIMIT ?;\n+`, beginTs.UTC(), endTs.UTC(), limit)\n+\tif err != nil {\n+\t\treturn nil, err\n+\t}\n+\tdefer rows.Close()\n+\n+\tvar tLE []types.TaskReportEntry\n+\n+\tfor rows.Next() {\n+\t\tvar entry types.TaskReportEntry\n+\t\terr = rows.Scan(\n+\t\t\t\u0026entry.TaskID,\n+\t\t\t\u0026entry.TaskSummary,\n+\t\t\t\u0026entry.NumEntries,\n+\t\t\t\u0026entry.SecsSpent,\n+\t\t)\n+\t\tif err != nil {\n+\t\t\treturn nil, err\n+\t\t}\n+\t\ttLE = append(tLE, entry)\n+\n+\t}\n+\tif rows.Err() != nil {\n+\t\treturn nil, err\n+\t}\n+\treturn tLE, nil\n+}\n+\n+func DeleteTaskLogEntry(db *sql.DB, entry *types.TaskLogEntry) error {\n+\treturn runInTx(db, func(tx *sql.Tx) error {\n+\t\tstmt, err := tx.Prepare(`\n+DELETE from task_log\n+WHERE ID=?;\n+`)\n+\t\tif err != nil {\n+\t\t\treturn err\n+\t\t}\n+\t\tdefer stmt.Close()\n+\n+\t\t_, err = stmt.Exec(entry.ID)\n+\t\tif err != nil {\n+\t\t\treturn err\n+\t\t}\n+\n+\t\ttStmt, err := tx.Prepare(`\n+UPDATE task\n+SET secs_spent = secs_spent-?,\n+    updated_at = ?\n+WHERE id = ?;\n+    `)\n+\t\tif err != nil {\n+\t\t\treturn err\n+\t\t}\n+\t\tdefer tStmt.Close()\n+\n+\t\t_, err = tStmt.Exec(entry.SecsSpent, time.Now().UTC(), entry.TaskID)\n+\t\treturn err\n+\t})\n+}\n+\n+func runInTxAndReturnID(db *sql.DB, fn func(tx *sql.Tx) (int, error)) (int, error) {\n+\ttx, err := db.Begin()\n+\tif err != nil {\n+\t\treturn -1, err\n+\t}\n+\n+\tlastID, err := fn(tx)\n+\tif err == nil {\n+\t\treturn lastID, tx.Commit()\n+\t}\n+\n+\trollbackErr := tx.Rollback()\n+\tif rollbackErr != nil {\n+\t\treturn lastID, fmt.Errorf(\"%w: %w: %s\", ErrCouldntRollBackTx, rollbackErr, err.Error())\n+\t}\n+\n+\treturn lastID, err\n+}\n+\n+func runInTx(db *sql.DB, fn func(tx *sql.Tx) error) error {\n+\ttx, err := db.Begin()\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\n+\terr = fn(tx)\n+\tif err == nil {\n+\t\treturn tx.Commit()\n+\t}\n+\n+\trollbackErr := tx.Rollback()\n+\tif rollbackErr != nil {\n+\t\treturn fmt.Errorf(\"%w: %w: %w\", ErrCouldntRollBackTx, rollbackErr, err)\n+\t}\n+\n+\treturn err\n+}\n+\n+func fetchTaskByID(db *sql.DB, id int) (types.Task, error) {\n+\tvar task types.Task\n+\trow := db.QueryRow(`\n+SELECT id, summary, secs_spent, active, created_at, updated_at\n+FROM task\n+WHERE id=?;\n+    `, id)\n+\n+\tif row.Err() != nil {\n+\t\treturn task, row.Err()\n+\t}\n+\terr := row.Scan(\u0026task.ID,\n+\t\t\u0026task.Summary,\n+\t\t\u0026task.SecsSpent,\n+\t\t\u0026task.Active,\n+\t\t\u0026task.CreatedAt,\n+\t\t\u0026task.UpdatedAt,\n+\t)\n+\tif err != nil {\n+\t\treturn task, err\n+\t}\n+\ttask.CreatedAt = task.CreatedAt.Local()\n+\ttask.UpdatedAt = task.UpdatedAt.Local()\n+\n+\treturn task, nil\n+}\n+\n+func fetchTaskLogByID(db *sql.DB, id int) (types.TaskLogEntry, error) {\n+\tvar tl types.TaskLogEntry\n+\trow := db.QueryRow(`\n+SELECT id, task_id, begin_ts, end_ts, secs_spent, comment\n+FROM task_log\n+WHERE id=?;\n+    `, id)\n+\n+\tif row.Err() != nil {\n+\t\treturn tl, row.Err()\n+\t}\n+\terr := row.Scan(\u0026tl.ID,\n+\t\t\u0026tl.TaskID,\n+\t\t\u0026tl.BeginTS,\n+\t\t\u0026tl.EndTS,\n+\t\t\u0026tl.SecsSpent,\n+\t\t\u0026tl.Comment,\n+\t)\n+\tif err != nil {\n+\t\treturn tl, err\n+\t}\n+\ttl.BeginTS = tl.BeginTS.Local()\n+\ttl.EndTS = tl.EndTS.Local()\n+\n+\treturn tl, nil\n+}\ndiff --git a/internal/persistence/queries_test.go b/internal/persistence/queries_test.go\nnew file mode 100644\nindex 0000000..88fa033\n--- /dev/null\n+++ b/internal/persistence/queries_test.go\n@@ -0,0 +1,361 @@\n+package persistence\n+\n+import (\n+\t\"database/sql\"\n+\t\"fmt\"\n+\t\"testing\"\n+\t\"time\"\n+\n+\t\"github.com/dhth/hours/internal/types\"\n+\t\"github.com/stretchr/testify/assert\"\n+\t\"github.com/stretchr/testify/require\"\n+\t_ \"modernc.org/sqlite\" // sqlite driver\n+)\n+\n+const (\n+\tsecsInOneHour  = 60 * 60\n+\ttaskLogComment = \"a task log outside the time range\"\n+)\n+\n+func TestRepository(t *testing.T) {\n+\ttestDB, err := sql.Open(\"sqlite\", \":memory:\")\n+\trequire.NoErrorf(t, err, \"error opening DB: %v\", err)\n+\n+\terr = InitDB(testDB)\n+\trequire.NoErrorf(t, err, \"error initializing DB: %v\", err)\n+\n+\terr = UpgradeDB(testDB, 1)\n+\trequire.NoErrorf(t, err, \"error upgrading DB: %v\", err)\n+\n+\tt.Run(\"TestInsertTask\", func(t *testing.T) {\n+\t\tt.Cleanup(func() { cleanupDB(t, testDB) })\n+\n+\t\t// GIVEN\n+\t\treferenceTS := time.Now()\n+\t\tseedData := getTestData(referenceTS)\n+\t\tseedDB(t, testDB, seedData)\n+\n+\t\t// WHEN\n+\t\tsummary := \"task 1\"\n+\t\ttaskID, err := InsertTask(testDB, summary)\n+\n+\t\t// THEN\n+\t\trequire.NoError(t, err, \"failed to insert task\")\n+\n+\t\ttask, fetchErr := fetchTaskByID(testDB, taskID)\n+\t\trequire.NoError(t, fetchErr, \"failed to fetch task\")\n+\n+\t\tassert.Equal(t, 3, task.ID)\n+\t\tassert.Equal(t, summary, task.Summary)\n+\t\tassert.True(t, task.Active)\n+\t\tassert.Zero(t, task.SecsSpent)\n+\t})\n+\n+\tt.Run(\"TestUpdateActiveTL\", func(t *testing.T) {\n+\t\tt.Cleanup(func() { cleanupDB(t, testDB) })\n+\n+\t\t// GIVEN\n+\t\treferenceTS := time.Now()\n+\t\tseedData := getTestData(referenceTS)\n+\t\tseedDB(t, testDB, seedData)\n+\t\ttaskID := 1\n+\t\tnumSeconds := 60 * 90\n+\t\tendTS := time.Now()\n+\t\tbeginTS := endTS.Add(time.Second * -1 * time.Duration(numSeconds))\n+\t\ttlID, insertErr := InsertNewTL(testDB, taskID, beginTS)\n+\t\trequire.NoError(t, insertErr, \"failed to insert task log\")\n+\n+\t\ttaskBefore, err := fetchTaskByID(testDB, taskID)\n+\t\trequire.NoError(t, err, \"failed to fetch task\")\n+\t\tnumSecondsBefore := taskBefore.SecsSpent\n+\n+\t\t// WHEN\n+\t\tcomment := \"a task log\"\n+\t\terr = UpdateActiveTL(testDB, tlID, taskID, beginTS, endTS, numSeconds, comment)\n+\n+\t\t// THEN\n+\t\trequire.NoError(t, err, \"failed to update task log\")\n+\n+\t\ttaskLog, err := fetchTaskLogByID(testDB, tlID)\n+\t\trequire.NoError(t, err, \"failed to fetch task log\")\n+\n+\t\ttaskAfter, err := fetchTaskByID(testDB, taskID)\n+\t\trequire.NoError(t, err, \"failed to fetch task\")\n+\n+\t\tassert.Equal(t, numSeconds, taskLog.SecsSpent)\n+\t\tassert.Equal(t, comment, taskLog.Comment)\n+\t\tassert.Equal(t, numSecondsBefore+numSeconds, taskAfter.SecsSpent)\n+\t})\n+\n+\tt.Run(\"TestInsertManualTL\", func(t *testing.T) {\n+\t\tt.Cleanup(func() { cleanupDB(t, testDB) })\n+\n+\t\t// GIVEN\n+\t\treferenceTS := time.Now()\n+\t\tseedData := getTestData(referenceTS)\n+\t\tseedDB(t, testDB, seedData)\n+\t\ttaskID := 1\n+\n+\t\ttaskBefore, err := fetchTaskByID(testDB, taskID)\n+\t\trequire.NoError(t, err, \"failed to fetch task\")\n+\t\tnumSecondsBefore := taskBefore.SecsSpent\n+\n+\t\t// WHEN\n+\t\tcomment := \"a task log\"\n+\t\tnumSeconds := 60 * 90\n+\t\tendTS := time.Now()\n+\t\tbeginTS := endTS.Add(time.Second * -1 * time.Duration(numSeconds))\n+\t\ttlID, err := InsertManualTL(testDB, taskID, beginTS, endTS, comment)\n+\n+\t\t// THEN\n+\t\trequire.NoError(t, err, \"failed to insert task log\")\n+\n+\t\ttaskLog, err := fetchTaskLogByID(testDB, tlID)\n+\t\trequire.NoError(t, err, \"failed to fetch task log\")\n+\n+\t\ttaskAfter, err := fetchTaskByID(testDB, taskID)\n+\t\trequire.NoError(t, err, \"failed to fetch task\")\n+\n+\t\tassert.Equal(t, numSeconds, taskLog.SecsSpent)\n+\t\tassert.Equal(t, comment, taskLog.Comment)\n+\t\tassert.Equal(t, numSecondsBefore+numSeconds, taskAfter.SecsSpent)\n+\t})\n+\n+\tt.Run(\"TestDeleteTaskLogEntry\", func(t *testing.T) {\n+\t\tt.Cleanup(func() { cleanupDB(t, testDB) })\n+\n+\t\t// GIVEN\n+\t\treferenceTS := time.Now()\n+\t\tseedData := getTestData(referenceTS)\n+\t\tseedDB(t, testDB, seedData)\n+\t\ttaskID := 1\n+\t\ttlID := 1\n+\t\ttaskBefore, err := fetchTaskByID(testDB, taskID)\n+\t\trequire.NoError(t, err, \"failed to fetch task\")\n+\t\tnumSecondsBefore := taskBefore.SecsSpent\n+\t\ttaskLog, err := fetchTaskLogByID(testDB, tlID)\n+\t\trequire.NoError(t, err, \"failed to fetch task log\")\n+\n+\t\t// WHEN\n+\t\terr = DeleteTaskLogEntry(testDB, \u0026taskLog)\n+\n+\t\t// THEN\n+\t\trequire.NoError(t, err, \"failed to insert task log\")\n+\n+\t\ttaskAfter, err := fetchTaskByID(testDB, taskID)\n+\t\trequire.NoError(t, err, \"failed to fetch task\")\n+\n+\t\tassert.Equal(t, numSecondsBefore-taskLog.SecsSpent, taskAfter.SecsSpent)\n+\t})\n+\n+\tt.Run(\"TestFetchTLEntriesBetweenTS\", func(t *testing.T) {\n+\t\tt.Cleanup(func() { cleanupDB(t, testDB) })\n+\n+\t\t// GIVEN\n+\t\treferenceTS := time.Date(2024, time.September, 1, 9, 0, 0, 0, time.Local)\n+\t\tseedData := getTestData(referenceTS)\n+\t\tseedDB(t, testDB, seedData)\n+\n+\t\ttaskID := 1\n+\t\tnumSeconds := 60 * 90\n+\t\ttlEndTS := referenceTS.Add(time.Hour * 2)\n+\t\ttlBeginTS := tlEndTS.Add(time.Second * -1 * time.Duration(numSeconds))\n+\t\t_, err = InsertManualTL(testDB, taskID, tlBeginTS, tlEndTS, taskLogComment)\n+\t\trequire.NoError(t, err, \"failed to insert task log\")\n+\n+\t\t// WHEN\n+\t\treportBeginTS := referenceTS.Add(time.Hour * 24 * 7 * -2)\n+\t\tentries, err := FetchTLEntriesBetweenTS(testDB, reportBeginTS, referenceTS, 100)\n+\n+\t\t// THEN\n+\t\trequire.NoError(t, err, \"failed to fetch report entries\")\n+\t\trequire.Len(t, entries, 3)\n+\t})\n+\n+\tt.Run(\"TestFetchStats\", func(t *testing.T) {\n+\t\tt.Cleanup(func() { cleanupDB(t, testDB) })\n+\n+\t\t// GIVEN\n+\t\treferenceTS := time.Date(2024, time.September, 1, 9, 0, 0, 0, time.Local)\n+\t\tseedData := getTestData(referenceTS)\n+\t\tseedDB(t, testDB, seedData)\n+\n+\t\ttaskID := 1\n+\t\tcomment := \"an extra task log\"\n+\t\tnumSeconds := 60 * 90\n+\t\ttlEndTS := referenceTS.Add(time.Hour * 2)\n+\t\ttlBeginTS := tlEndTS.Add(time.Second * -1 * time.Duration(numSeconds))\n+\t\t_, err = InsertManualTL(testDB, taskID, tlBeginTS, tlEndTS, comment)\n+\t\trequire.NoError(t, err, \"failed to insert task log\")\n+\n+\t\t// WHEN\n+\t\tentries, err := FetchStats(testDB, 100)\n+\n+\t\t// THEN\n+\t\trequire.NoError(t, err, \"failed to fetch report entries\")\n+\t\trequire.Len(t, entries, 2)\n+\n+\t\tassert.Equal(t, 1, entries[0].TaskID)\n+\t\tassert.Equal(t, 3, entries[0].NumEntries)\n+\t\tassert.Equal(t, 5*secsInOneHour+numSeconds, entries[0].SecsSpent)\n+\n+\t\tassert.Equal(t, 2, entries[1].TaskID)\n+\t\tassert.Equal(t, 1, entries[1].NumEntries)\n+\t\tassert.Equal(t, 4*secsInOneHour, entries[1].SecsSpent)\n+\t})\n+\n+\tt.Run(\"TestFetchStatsBetweenTS\", func(t *testing.T) {\n+\t\tt.Cleanup(func() { cleanupDB(t, testDB) })\n+\n+\t\t// GIVEN\n+\t\treferenceTS := time.Date(2024, time.September, 1, 9, 0, 0, 0, time.Local)\n+\t\tseedData := getTestData(referenceTS)\n+\t\tseedDB(t, testDB, seedData)\n+\n+\t\ttaskID := 1\n+\t\tnumSeconds := 60 * 90\n+\t\ttlEndTS := referenceTS.Add(time.Hour * 2)\n+\t\ttlBeginTS := tlEndTS.Add(time.Second * -1 * time.Duration(numSeconds))\n+\t\t_, err = InsertManualTL(testDB, taskID, tlBeginTS, tlEndTS, taskLogComment)\n+\t\trequire.NoError(t, err, \"failed to insert task log\")\n+\n+\t\t// WHEN\n+\t\treportBeginTS := referenceTS.Add(time.Hour * 24 * 7 * -2)\n+\t\tentries, err := FetchStatsBetweenTS(testDB, reportBeginTS, referenceTS, 100)\n+\n+\t\t// THEN\n+\t\trequire.NoError(t, err, \"failed to fetch report entries\")\n+\t\trequire.Len(t, entries, 2)\n+\n+\t\tassert.Equal(t, 1, entries[0].TaskID)\n+\t\tassert.Equal(t, 2, entries[0].NumEntries)\n+\t\tassert.Equal(t, 5*secsInOneHour, entries[0].SecsSpent)\n+\n+\t\tassert.Equal(t, 2, entries[1].TaskID)\n+\t\tassert.Equal(t, 1, entries[1].NumEntries)\n+\t\tassert.Equal(t, 4*secsInOneHour, entries[1].SecsSpent)\n+\t})\n+\n+\tt.Run(\"TestFetchReportBetweenTS\", func(t *testing.T) {\n+\t\tt.Cleanup(func() { cleanupDB(t, testDB) })\n+\n+\t\t// GIVEN\n+\t\treferenceTS := time.Date(2024, time.September, 1, 9, 0, 0, 0, time.Local)\n+\t\tseedData := getTestData(referenceTS)\n+\t\tseedDB(t, testDB, seedData)\n+\n+\t\ttaskID := 1\n+\t\tnumSeconds := 60 * 90\n+\t\ttlEndTS := referenceTS.Add(time.Hour * 2)\n+\t\ttlBeginTS := tlEndTS.Add(time.Second * -1 * time.Duration(numSeconds))\n+\t\t_, err = InsertManualTL(testDB, taskID, tlBeginTS, tlEndTS, taskLogComment)\n+\t\trequire.NoError(t, err, \"failed to insert task log\")\n+\n+\t\t// WHEN\n+\t\treportBeginTS := referenceTS.Add(time.Hour * 24 * 7 * -2)\n+\t\tentries, err := FetchReportBetweenTS(testDB, reportBeginTS, referenceTS, 100)\n+\n+\t\t// THEN\n+\t\trequire.NoError(t, err, \"failed to fetch report entries\")\n+\n+\t\trequire.Len(t, entries, 2)\n+\t\tassert.Equal(t, 2, entries[0].TaskID)\n+\t\tassert.Equal(t, 1, entries[0].NumEntries)\n+\t\tassert.Equal(t, 4*secsInOneHour, entries[0].SecsSpent)\n+\n+\t\tassert.Equal(t, 1, entries[1].TaskID)\n+\t\tassert.Equal(t, 2, entries[1].NumEntries)\n+\t\tassert.Equal(t, 5*secsInOneHour, entries[1].SecsSpent)\n+\t})\n+\n+\terr = testDB.Close()\n+\trequire.NoErrorf(t, err, \"error closing DB: %v\", err)\n+}\n+\n+func cleanupDB(t *testing.T, testDB *sql.DB) {\n+\tt.Helper()\n+\n+\tvar err error\n+\tfor _, tbl := range []string{\"task_log\", \"task\"} {\n+\t\t_, err = testDB.Exec(fmt.Sprintf(\"DELETE FROM %s\", tbl))\n+\t\trequire.NoErrorf(t, err, \"failed to clean up table %q: %v\", tbl, err)\n+\n+\t\t_, err := testDB.Exec(\"DELETE FROM sqlite_sequence WHERE name=?;\", tbl)\n+\t\trequire.NoErrorf(t, err, \"failed to reset auto increment for table %q: %v\", tbl, err)\n+\t}\n+}\n+\n+type testData struct {\n+\ttasks    []types.Task\n+\ttaskLogs []types.TaskLogEntry\n+}\n+\n+func getTestData(referenceTS time.Time) testData {\n+\tua := referenceTS.UTC()\n+\tca := ua.Add(time.Hour * 24 * 7 * -1)\n+\ttasks := []types.Task{\n+\t\t{\n+\t\t\tID:        1,\n+\t\t\tSummary:   \"seeded task 1\",\n+\t\t\tActive:    true,\n+\t\t\tCreatedAt: ca,\n+\t\t\tUpdatedAt: ca.Add(time.Hour * 9),\n+\t\t\tSecsSpent: 5 * secsInOneHour,\n+\t\t},\n+\t\t{\n+\t\t\tID:        2,\n+\t\t\tSummary:   \"seeded task 2\",\n+\t\t\tActive:    true,\n+\t\t\tCreatedAt: ca,\n+\t\t\tUpdatedAt: ca.Add(time.Hour * 6),\n+\t\t\tSecsSpent: 4 * secsInOneHour,\n+\t\t},\n+\t}\n+\n+\ttaskLogs := []types.TaskLogEntry{\n+\t\t{\n+\t\t\tID:        1,\n+\t\t\tTaskID:    1,\n+\t\t\tBeginTS:   ca.Add(time.Hour * 2),\n+\t\t\tEndTS:     ca.Add(time.Hour * 4),\n+\t\t\tSecsSpent: 2 * secsInOneHour,\n+\t\t\tComment:   \"task 1 tl 1\",\n+\t\t},\n+\t\t{\n+\t\t\tID:        2,\n+\t\t\tTaskID:    1,\n+\t\t\tBeginTS:   ca.Add(time.Hour * 6),\n+\t\t\tEndTS:     ca.Add(time.Hour * 9),\n+\t\t\tSecsSpent: 3 * secsInOneHour,\n+\t\t\tComment:   \"task 1 tl 2\",\n+\t\t},\n+\t\t{\n+\t\t\tID:        3,\n+\t\t\tTaskID:    2,\n+\t\t\tBeginTS:   ca.Add(time.Hour * 2),\n+\t\t\tEndTS:     ca.Add(time.Hour * 6),\n+\t\t\tSecsSpent: 4 * secsInOneHour,\n+\t\t\tComment:   \"task 2 tl 1\",\n+\t\t},\n+\t}\n+\n+\treturn testData{tasks, taskLogs}\n+}\n+\n+func seedDB(t *testing.T, db *sql.DB, data testData) {\n+\tt.Helper()\n+\n+\tfor _, task := range data.tasks {\n+\t\t_, err := db.Exec(`\n+INSERT INTO task (id, summary, secs_spent, active, created_at, updated_at)\n+VALUES (?, ?, ?, ?, ?, ?)`, task.ID, task.Summary, task.SecsSpent, task.Active, task.CreatedAt, task.UpdatedAt)\n+\t\trequire.NoError(t, err, \"failed to insert data into table \\\"task\\\": %v\", err)\n+\t}\n+\n+\tfor _, taskLog := range data.taskLogs {\n+\t\t_, err := db.Exec(`\n+INSERT INTO task_log (id, task_id, begin_ts, end_ts, secs_spent, comment, active)\n+VALUES (?, ?, ?, ?, ?, ?, ?)`, taskLog.ID, taskLog.TaskID, taskLog.BeginTS, taskLog.EndTS, taskLog.SecsSpent, taskLog.Comment, false)\n+\t\trequire.NoError(t, err, \"failed to insert data into table \\\"task_log\\\": %v\", err)\n+\t}\n+}\ndiff --git a/internal/ui/date_helpers.go b/internal/types/date_helpers.go\nsimilarity index 54%\nrename from internal/ui/date_helpers.go\nrename to internal/types/date_helpers.go\nindex 62abf24..ee6293e 100644\n--- a/internal/ui/date_helpers.go\n+++ b/internal/types/date_helpers.go\n@@ -1,63 +1,74 @@\n-package ui\n+package types\n \n import (\n+\t\"errors\"\n \t\"fmt\"\n \t\"strings\"\n \t\"time\"\n )\n \n const (\n \ttimePeriodDaysUpperBound = 7\n+\tTimePeriodWeek           = \"week\"\n+\ttimeFormat               = \"2006/01/02 15:04\"\n+\ttimeOnlyFormat           = \"15:04\"\n+\tdayFormat                = \"Monday\"\n+\tfriendlyTimeFormat       = \"Mon, 15:04\"\n+\tdateFormat               = \"2006/01/02\"\n )\n \n var (\n-\ttimePeriodNotValidErr = 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)\n-\ttimePeriodTooLargeErr = fmt.Errorf(\"time period is too large; maximum number of days allowed (both inclusive): %d\", timePeriodDaysUpperBound)\n+\terrDateRangeIncorrect         = errors.New(\"date range is incorrect\")\n+\terrStartDateIncorrect         = errors.New(\"start date is incorrect\")\n+\terrEndDateIncorrect           = errors.New(\"end date is incorrect\")\n+\terrEndDateIsNotAfterStartDate = errors.New(\"end date is not after start date\")\n+\terrTimePeriodNotValid         = errors.New(\"time period is not valid\")\n+\terrTimePeriodTooLarge         = errors.New(\"time period is too large\")\n )\n \n-type timePeriod struct {\n-\tstart   time.Time\n-\tend     time.Time\n-\tnumDays int\n+type TimePeriod struct {\n+\tStart   time.Time\n+\tEnd     time.Time\n+\tNumDays int\n }\n \n-func parseDateDuration(dateRange string) (timePeriod, bool) {\n-\tvar tp timePeriod\n+func parseDateDuration(dateRange string) (TimePeriod, error) {\n+\tvar tp TimePeriod\n \n \telements := strings.Split(dateRange, \"...\")\n \tif len(elements) != 2 {\n-\t\treturn tp, false\n+\t\treturn tp, fmt.Errorf(\"%w: date range needs to be of the format: %s...%s\", errDateRangeIncorrect, dateFormat, dateFormat)\n \t}\n \n \tstart, err := time.ParseInLocation(string(dateFormat), elements[0], time.Local)\n \tif err != nil {\n-\t\treturn tp, false\n+\t\treturn tp, fmt.Errorf(\"%w: %s\", errStartDateIncorrect, err.Error())\n \t}\n \n \tend, err := time.ParseInLocation(string(dateFormat), elements[1], time.Local)\n \tif err != nil {\n-\t\treturn tp, false\n+\t\treturn tp, fmt.Errorf(\"%w: %s\", errEndDateIncorrect, err.Error())\n \t}\n \n \tif end.Sub(start) \u003c= 0 {\n-\t\treturn tp, false\n+\t\treturn tp, fmt.Errorf(\"%w\", errEndDateIsNotAfterStartDate)\n \t}\n \n-\ttp.start = start\n-\ttp.end = end\n-\ttp.numDays = int(end.Sub(start).Hours()/24) + 1\n+\ttp.Start = start\n+\ttp.End = end\n+\ttp.NumDays = int(end.Sub(start).Hours()/24) + 1\n \n-\treturn tp, true\n+\treturn tp, nil\n }\n \n-func getTimePeriod(period string, now time.Time, fullWeek bool) (timePeriod, error) {\n+func GetTimePeriod(period string, now time.Time, fullWeek bool) (TimePeriod, error) {\n \tvar start, end time.Time\n \tvar numDays int\n \n \tswitch period {\n \n \tcase \"today\":\n \t\tstart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())\n \t\tend = start.AddDate(0, 0, 1)\n \t\tnumDays = 1\n \n@@ -68,94 +79,79 @@ func getTimePeriod(period string, now time.Time, fullWeek bool) (timePeriod, err\n \t\tend = start.AddDate(0, 0, 1)\n \t\tnumDays = 1\n \n \tcase \"3d\":\n \t\tthreeDaysBefore := now.AddDate(0, 0, -2)\n \n \t\tstart = time.Date(threeDaysBefore.Year(), threeDaysBefore.Month(), threeDaysBefore.Day(), 0, 0, 0, 0, threeDaysBefore.Location())\n \t\tend = start.AddDate(0, 0, 3)\n \t\tnumDays = 3\n \n-\tcase \"week\":\n+\tcase TimePeriodWeek:\n \t\tweekday := now.Weekday()\n \t\toffset := (7 + weekday - time.Monday) % 7\n \t\tstartOfWeek := now.AddDate(0, 0, -int(offset))\n \t\tstart = time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location())\n \t\tif fullWeek {\n \t\t\tnumDays = 7\n \t\t} else {\n \t\t\tnumDays = int(offset) + 1\n \t\t}\n \t\tend = start.AddDate(0, 0, numDays)\n \n \tdefault:\n \t\tvar err error\n \n \t\tif strings.Contains(period, \"...\") {\n-\t\t\tvar ts timePeriod\n-\t\t\tvar ok bool\n-\t\t\tts, ok = parseDateDuration(period)\n-\t\t\tif !ok {\n-\t\t\t\treturn ts, timePeriodNotValidErr\n-\t\t\t}\n-\t\t\tif ts.numDays \u003e timePeriodDaysUpperBound {\n-\t\t\t\treturn ts, timePeriodTooLargeErr\n+\t\t\tvar ts TimePeriod\n+\t\t\tts, err = parseDateDuration(period)\n+\t\t\tif err != nil {\n+\t\t\t\treturn ts, fmt.Errorf(\"%w: %s\", errTimePeriodNotValid, err.Error())\n \t\t\t}\n \n-\t\t\tstart = ts.start\n-\t\t\tend = ts.end.AddDate(0, 0, 1)\n-\t\t\tnumDays = ts.numDays\n+\t\t\tif ts.NumDays \u003e timePeriodDaysUpperBound {\n+\t\t\t\treturn ts, fmt.Errorf(\"%w: maximum number of days allowed (both inclusive): %d\", errTimePeriodTooLarge, timePeriodDaysUpperBound)\n+\t\t\t}\n+\n+\t\t\tstart = ts.Start\n+\t\t\tend = ts.End.AddDate(0, 0, 1)\n+\t\t\tnumDays = ts.NumDays\n \t\t} else {\n \t\t\tstart, err = time.ParseInLocation(string(dateFormat), period, time.Local)\n \t\t\tif err != nil {\n-\t\t\t\treturn timePeriod{}, timePeriodNotValidErr\n+\t\t\t\treturn TimePeriod{}, fmt.Errorf(\"%w: %s\", errTimePeriodNotValid, err.Error())\n \t\t\t}\n \t\t\tend = start.AddDate(0, 0, 1)\n \t\t\tnumDays = 1\n \t\t}\n \t}\n \n-\treturn timePeriod{\n-\t\tstart:   start,\n-\t\tend:     end,\n-\t\tnumDays: numDays,\n+\treturn TimePeriod{\n+\t\tStart:   start,\n+\t\tEnd:     end,\n+\t\tNumDays: numDays,\n \t}, nil\n }\n \n-type timeShiftDirection uint8\n-\n-const (\n-\tshiftForward timeShiftDirection = iota\n-\tshiftBackward\n-)\n-\n-type timeShiftDuration uint8\n-\n-const (\n-\tshiftMinute timeShiftDuration = iota\n-\tshiftFiveMinutes\n-\tshiftHour\n-)\n-\n-func getShiftedTime(ts time.Time, direction timeShiftDirection, duration timeShiftDuration) time.Time {\n+func GetShiftedTime(ts time.Time, direction TimeShiftDirection, duration TimeShiftDuration) time.Time {\n \tvar d time.Duration\n \n \tswitch duration {\n-\tcase shiftMinute:\n+\tcase ShiftMinute:\n \t\td = time.Minute\n-\tcase shiftFiveMinutes:\n+\tcase ShiftFiveMinutes:\n \t\td = time.Minute * 5\n-\tcase shiftHour:\n+\tcase ShiftHour:\n \t\td = time.Hour\n \t}\n \n-\tif direction == shiftBackward {\n+\tif direction == ShiftBackward {\n \t\td = -1 * d\n \t}\n \treturn ts.Add(d)\n }\n \n type tsRelative uint8\n \n const (\n \ttsFromFuture tsRelative = iota\n \ttsFromToday\ndiff --git a/internal/ui/date_helpers_test.go b/internal/types/date_helpers_test.go\nsimilarity index 84%\nrename from internal/ui/date_helpers_test.go\nrename to internal/types/date_helpers_test.go\nindex df3fbd7..8cd1bc3 100644\n--- a/internal/ui/date_helpers_test.go\n+++ b/internal/types/date_helpers_test.go\n@@ -1,95 +1,100 @@\n-package ui\n+package types\n \n import (\n \t\"testing\"\n \t\"time\"\n \n \t\"github.com/stretchr/testify/assert\"\n+\t\"github.com/stretchr/testify/require\"\n )\n \n func TestParseDateDuration(t *testing.T) {\n \ttestCases := []struct {\n \t\tname             string\n \t\tinput            string\n \t\texpectedStartStr string\n \t\texpectedEndStr   string\n \t\texpectedNumDays  int\n-\t\tok               bool\n+\t\terr              error\n \t}{\n \t\t// success\n \t\t{\n \t\t\tname:             \"a range of 1 day\",\n \t\t\tinput:            \"2024/06/10...2024/06/11\",\n \t\t\texpectedStartStr: \"2024/06/10 00:00\",\n \t\t\texpectedEndStr:   \"2024/06/11 00:00\",\n \t\t\texpectedNumDays:  2,\n-\t\t\tok:               true,\n \t\t},\n \t\t{\n \t\t\tname:             \"a range of 2 days\",\n \t\t\tinput:            \"2024/06/29...2024/07/01\",\n \t\t\texpectedStartStr: \"2024/06/29 00:00\",\n \t\t\texpectedEndStr:   \"2024/07/01 00:00\",\n \t\t\texpectedNumDays:  3,\n-\t\t\tok:               true,\n \t\t},\n \t\t{\n \t\t\tname:             \"a range of 1 year\",\n \t\t\tinput:            \"2024/06/29...2025/06/29\",\n \t\t\texpectedStartStr: \"2024/06/29 00:00\",\n \t\t\texpectedEndStr:   \"2025/06/29 00:00\",\n \t\t\texpectedNumDays:  366,\n-\t\t\tok:               true,\n \t\t},\n \t\t// failures\n \t\t{\n \t\t\tname:  \"empty string\",\n \t\t\tinput: \"\",\n+\t\t\terr:   errDateRangeIncorrect,\n \t\t},\n \t\t{\n \t\t\tname:  \"only one date\",\n \t\t\tinput: \"2024/06/10\",\n+\t\t\terr:   errDateRangeIncorrect,\n \t\t},\n \t\t{\n \t\t\tname:  \"badly formatted start date\",\n \t\t\tinput: \"2024/0610...2024/06/10\",\n+\t\t\terr:   errStartDateIncorrect,\n \t\t},\n \t\t{\n \t\t\tname:  \"badly formatted end date\",\n \t\t\tinput: \"2024/06/10...2024/0610\",\n+\t\t\terr:   errEndDateIncorrect,\n \t\t},\n \t\t{\n \t\t\tname:  \"a range of 0 days\",\n \t\t\tinput: \"2024/06/10...2024/06/10\",\n+\t\t\terr:   errEndDateIsNotAfterStartDate,\n \t\t},\n \t\t{\n \t\t\tname:  \"end date before start date\",\n \t\t\tinput: \"2024/06/10...2024/06/08\",\n+\t\t\terr:   errEndDateIsNotAfterStartDate,\n \t\t},\n \t}\n \n \tfor _, tt := range testCases {\n \t\tt.Run(tt.name, func(t *testing.T) {\n-\t\t\tgot, ok := parseDateDuration(tt.input)\n+\t\t\tgot, err := parseDateDuration(tt.input)\n \n-\t\t\tif tt.ok {\n-\t\t\t\tstartStr := got.start.Format(timeFormat)\n-\t\t\t\tendStr := got.end.Format(timeFormat)\n-\n-\t\t\t\tassert.True(t, ok)\n-\t\t\t\tassert.Equal(t, tt.expectedStartStr, startStr)\n-\t\t\t\tassert.Equal(t, tt.expectedEndStr, endStr)\n-\t\t\t\tassert.Equal(t, tt.expectedNumDays, got.numDays)\n-\t\t\t} else {\n-\t\t\t\tassert.False(t, ok)\n+\t\t\tif tt.err != nil {\n+\t\t\t\tassert.ErrorIs(t, err, tt.err)\n+\t\t\t\treturn\n \t\t\t}\n+\n+\t\t\tstartStr := got.Start.Format(timeFormat)\n+\t\t\tendStr := got.End.Format(timeFormat)\n+\n+\t\t\trequire.NoError(t, err)\n+\t\t\tassert.Equal(t, tt.expectedStartStr, startStr)\n+\t\t\tassert.Equal(t, tt.expectedEndStr, endStr)\n+\t\t\tassert.Equal(t, tt.expectedNumDays, got.NumDays)\n \t\t})\n \t}\n }\n \n func TestGetTimePeriod(t *testing.T) {\n \tnow, err := time.ParseInLocation(string(timeFormat), \"2024/06/20 20:00\", time.Local)\n \tif err != nil {\n \t\tt.Fatalf(\"error setting up the test: time is not valid: %s\", err)\n \t}\n \n@@ -174,49 +179,49 @@ func TestGetTimePeriod(t *testing.T) {\n \t\t\tname:             \"a date range\",\n \t\t\tperiod:           \"2024/06/15...2024/06/20\",\n \t\t\texpectedStartStr: \"2024/06/15 00:00\",\n \t\t\texpectedEndStr:   \"2024/06/21 00:00\",\n \t\t\texpectedNumDays:  6,\n \t\t},\n \t\t// failures\n \t\t{\n \t\t\tname:   \"a faulty date\",\n \t\t\tperiod: \"2024/06-15\",\n-\t\t\terr:    timePeriodNotValidErr,\n+\t\t\terr:    errTimePeriodNotValid,\n \t\t},\n \t\t{\n \t\t\tname:   \"a faulty date range\",\n \t\t\tperiod: \"2024/06/15...2024\",\n-\t\t\terr:    timePeriodNotValidErr,\n+\t\t\terr:    errTimePeriodNotValid,\n \t\t},\n \t\t{\n \t\t\tname:   \"a date range too large\",\n \t\t\tperiod: \"2024/06/15...2024/06/22\",\n-\t\t\terr:    timePeriodTooLargeErr,\n+\t\t\terr:    errTimePeriodTooLarge,\n \t\t},\n \t}\n \n \tfor _, tt := range testCases {\n \t\tt.Run(tt.name, func(t *testing.T) {\n-\t\t\tgot, err := getTimePeriod(tt.period, tt.now, tt.fullWeek)\n+\t\t\tgot, err := GetTimePeriod(tt.period, tt.now, tt.fullWeek)\n \n-\t\t\tstartStr := got.start.Format(timeFormat)\n-\t\t\tendStr := got.end.Format(timeFormat)\n+\t\t\tstartStr := got.Start.Format(timeFormat)\n+\t\t\tendStr := got.End.Format(timeFormat)\n \n \t\t\tif tt.err == nil {\n \t\t\t\tassert.Equal(t, tt.expectedStartStr, startStr)\n \t\t\t\tassert.Equal(t, tt.expectedEndStr, endStr)\n-\t\t\t\tassert.Equal(t, tt.expectedNumDays, got.numDays)\n-\t\t\t\tassert.Nil(t, err)\n-\t\t\t} else {\n-\t\t\t\tassert.Equal(t, tt.err, err)\n+\t\t\t\tassert.Equal(t, tt.expectedNumDays, got.NumDays)\n+\t\t\t\tassert.NoError(t, err)\n+\t\t\t\treturn\n \t\t\t}\n+\t\t\tassert.ErrorIs(t, err, tt.err)\n \t\t})\n \t}\n }\n \n func TestGetTSRelative(t *testing.T) {\n \treference := time.Date(2024, 6, 29, 12, 0, 0, 0, time.Local)\n \ttestCases := []struct {\n \t\tname      string\n \t\tts        time.Time\n \t\treference time.Time\ndiff --git a/internal/types/types.go b/internal/types/types.go\nnew file mode 100644\nindex 0000000..b34a7ad\n--- /dev/null\n+++ b/internal/types/types.go\n@@ -0,0 +1,158 @@\n+package types\n+\n+import (\n+\t\"fmt\"\n+\t\"math\"\n+\t\"time\"\n+\n+\t\"github.com/dhth/hours/internal/utils\"\n+\t\"github.com/dustin/go-humanize\"\n+)\n+\n+type Task struct {\n+\tID             int\n+\tSummary        string\n+\tCreatedAt      time.Time\n+\tUpdatedAt      time.Time\n+\tTrackingActive bool\n+\tSecsSpent      int\n+\tActive         bool\n+\tTaskTitle      string\n+\tTaskDesc       string\n+}\n+\n+type TaskLogEntry struct {\n+\tID          int\n+\tTaskID      int\n+\tTaskSummary string\n+\tBeginTS     time.Time\n+\tEndTS       time.Time\n+\tSecsSpent   int\n+\tComment     string\n+\tTLTitle     string\n+\tTLDesc      string\n+}\n+\n+type ActiveTaskDetails struct {\n+\tTaskID              int\n+\tTaskSummary         string\n+\tLastLogEntryBeginTS time.Time\n+}\n+\n+type TaskReportEntry struct {\n+\tTaskID      int\n+\tTaskSummary string\n+\tNumEntries  int\n+\tSecsSpent   int\n+}\n+\n+func (t *Task) UpdateTitle() {\n+\tvar trackingIndicator string\n+\tif t.TrackingActive {\n+\t\ttrackingIndicator = \"⏲ \"\n+\t}\n+\n+\tt.TaskTitle = trackingIndicator + t.Summary\n+}\n+\n+func (t *Task) UpdateDesc() {\n+\tvar timeSpent string\n+\n+\tif t.SecsSpent != 0 {\n+\t\ttimeSpent = \"worked on for \" + HumanizeDuration(t.SecsSpent)\n+\t} else {\n+\t\ttimeSpent = \"no time spent\"\n+\t}\n+\tlastUpdated := fmt.Sprintf(\"last updated: %s\", humanize.Time(t.UpdatedAt))\n+\n+\tt.TaskDesc = fmt.Sprintf(\"%s %s\", utils.RightPadTrim(lastUpdated, 60, true), timeSpent)\n+}\n+\n+func (tl *TaskLogEntry) UpdateTitle() {\n+\ttl.TLTitle = utils.Trim(tl.Comment, 60)\n+}\n+\n+func (tl *TaskLogEntry) UpdateDesc() {\n+\ttimeSpentStr := HumanizeDuration(tl.SecsSpent)\n+\n+\tvar timeStr string\n+\tvar durationMsg string\n+\n+\tendTSRelative := getTSRelative(tl.EndTS, time.Now())\n+\n+\tswitch endTSRelative {\n+\tcase tsFromToday:\n+\t\tdurationMsg = fmt.Sprintf(\"%s  ...  %s\", tl.BeginTS.Format(timeOnlyFormat), tl.EndTS.Format(timeOnlyFormat))\n+\tcase tsFromYesterday:\n+\t\tdurationMsg = \"Yesterday\"\n+\tcase tsFromThisWeek:\n+\t\tdurationMsg = tl.EndTS.Format(dayFormat)\n+\tdefault:\n+\t\tdurationMsg = humanize.Time(tl.EndTS)\n+\t}\n+\n+\ttimeStr = fmt.Sprintf(\"%s (%s)\",\n+\t\tutils.RightPadTrim(durationMsg, 40, true),\n+\t\ttimeSpentStr)\n+\n+\ttl.TLDesc = fmt.Sprintf(\"%s %s\", utils.RightPadTrim(\"[\"+tl.TaskSummary+\"]\", 60, true), timeStr)\n+}\n+\n+func (t Task) Title() string {\n+\treturn t.TaskTitle\n+}\n+\n+func (t Task) Description() string {\n+\treturn t.TaskDesc\n+}\n+\n+func (t Task) FilterValue() string {\n+\treturn t.Summary\n+}\n+\n+func (tl TaskLogEntry) Title() string {\n+\treturn tl.TLTitle\n+}\n+\n+func (tl TaskLogEntry) Description() string {\n+\treturn tl.TLDesc\n+}\n+\n+func (tl TaskLogEntry) FilterValue() string {\n+\treturn tl.Comment\n+}\n+\n+func HumanizeDuration(durationInSecs int) string {\n+\tduration := time.Duration(durationInSecs) * time.Second\n+\n+\tif duration.Seconds() \u003c 60 {\n+\t\treturn fmt.Sprintf(\"%ds\", int(duration.Seconds()))\n+\t}\n+\n+\tif duration.Minutes() \u003c 60 {\n+\t\treturn fmt.Sprintf(\"%dm\", int(duration.Minutes()))\n+\t}\n+\n+\tmodMins := int(math.Mod(duration.Minutes(), 60))\n+\n+\tif modMins == 0 {\n+\t\treturn fmt.Sprintf(\"%dh\", int(duration.Hours()))\n+\t}\n+\n+\treturn fmt.Sprintf(\"%dh %dm\", int(duration.Hours()), modMins)\n+}\n+\n+type TimeShiftDirection uint8\n+\n+const (\n+\tShiftForward TimeShiftDirection = iota\n+\tShiftBackward\n+)\n+\n+type TimeShiftDuration uint8\n+\n+const (\n+\tShiftMinute TimeShiftDuration = iota\n+\tShiftFiveMinutes\n+\tShiftHour\n+)\ndiff --git a/internal/types/types_test.go b/internal/types/types_test.go\nnew file mode 100644\nindex 0000000..8fc09b3\n--- /dev/null\n+++ b/internal/types/types_test.go\n@@ -0,0 +1,58 @@\n+package types\n+\n+import (\n+\t\"testing\"\n+\n+\t\"github.com/stretchr/testify/assert\"\n+)\n+\n+func TestHumanizeDuration(t *testing.T) {\n+\ttestCases := []struct {\n+\t\tname     string\n+\t\tinput    int\n+\t\texpected string\n+\t}{\n+\t\t{\n+\t\t\tname:     \"0 seconds\",\n+\t\t\tinput:    0,\n+\t\t\texpected: \"0s\",\n+\t\t},\n+\t\t{\n+\t\t\tname:     \"30 seconds\",\n+\t\t\tinput:    30,\n+\t\t\texpected: \"30s\",\n+\t\t},\n+\t\t{\n+\t\t\tname:     \"60 seconds\",\n+\t\t\tinput:    60,\n+\t\t\texpected: \"1m\",\n+\t\t},\n+\t\t{\n+\t\t\tname:     \"1805 seconds\",\n+\t\t\tinput:    1805,\n+\t\t\texpected: \"30m\",\n+\t\t},\n+\t\t{\n+\t\t\tname:     \"3605 seconds\",\n+\t\t\tinput:    3605,\n+\t\t\texpected: \"1h\",\n+\t\t},\n+\t\t{\n+\t\t\tname:     \"4200 seconds\",\n+\t\t\tinput:    4200,\n+\t\t\texpected: \"1h 10m\",\n+\t\t},\n+\t\t{\n+\t\t\tname:     \"87000 seconds\",\n+\t\t\tinput:    87000,\n+\t\t\texpected: \"24h 10m\",\n+\t\t},\n+\t}\n+\n+\tfor _, tt := range testCases {\n+\t\tt.Run(tt.name, func(t *testing.T) {\n+\t\t\tgot := HumanizeDuration(tt.input)\n+\t\t\tassert.Equal(t, tt.expected, got)\n+\t\t})\n+\t}\n+}\ndiff --git a/internal/ui/active.go b/internal/ui/active.go\nindex bb5a35d..5cf26f6 100644\n--- a/internal/ui/active.go\n+++ b/internal/ui/active.go\n@@ -1,42 +1,43 @@\n package ui\n \n import (\n \t\"database/sql\"\n \t\"fmt\"\n \t\"io\"\n-\t\"os\"\n \t\"strings\"\n \t\"time\"\n+\n+\tpers \"github.com/dhth/hours/internal/persistence\"\n+\t\"github.com/dhth/hours/internal/types\"\n )\n \n const (\n \tActiveTaskPlaceholder     = \"{{task}}\"\n \tActiveTaskTimePlaceholder = \"{{time}}\"\n \tactiveSecsThreshold       = 60\n \tactiveSecsThresholdStr    = \"\u003c1m\"\n )\n \n-func ShowActiveTask(db *sql.DB, writer io.Writer, template string) {\n-\tactiveTaskDetails, err := fetchActiveTaskFromDB(db)\n+func ShowActiveTask(db *sql.DB, writer io.Writer, template string) error {\n+\tactiveTaskDetails, err := pers.FetchActiveTask(db)\n \tif err != nil {\n-\t\tfmt.Fprintf(os.Stdout, \"Something went wrong:\\n%s\", err)\n-\t\tos.Exit(1)\n+\t\treturn err\n \t}\n \n-\tif activeTaskDetails.taskId == -1 {\n-\t\treturn\n+\tif activeTaskDetails.TaskID == -1 {\n+\t\treturn nil\n \t}\n \n-\tnow := time.Now()\n-\ttimeSpent := now.Sub(activeTaskDetails.lastLogEntryBeginTs).Seconds()\n+\ttimeSpent := time.Since(activeTaskDetails.LastLogEntryBeginTS).Seconds()\n \tvar timeSpentStr string\n \tif timeSpent \u003c= activeSecsThreshold {\n \t\ttimeSpentStr = activeSecsThresholdStr\n \t} else {\n-\t\ttimeSpentStr = humanizeDuration(int(timeSpent))\n+\t\ttimeSpentStr = types.HumanizeDuration(int(timeSpent))\n \t}\n \n-\tactiveStr := strings.Replace(template, ActiveTaskPlaceholder, activeTaskDetails.taskSummary, 1)\n+\tactiveStr := strings.Replace(template, ActiveTaskPlaceholder, activeTaskDetails.TaskSummary, 1)\n \tactiveStr = strings.Replace(activeStr, ActiveTaskTimePlaceholder, timeSpentStr, 1)\n \tfmt.Fprint(writer, activeStr)\n+\treturn nil\n }\ndiff --git a/internal/ui/cmds.go b/internal/ui/cmds.go\nindex b4d7c1b..020d771 100644\n--- a/internal/ui/cmds.go\n+++ b/internal/ui/cmds.go\n@@ -1,161 +1,164 @@\n package ui\n \n import (\n \t\"database/sql\"\n+\t\"errors\"\n \t\"time\"\n \n \ttea \"github.com/charmbracelet/bubbletea\"\n-\t_ \"modernc.org/sqlite\"\n+\tpers \"github.com/dhth/hours/internal/persistence\"\n+\t\"github.com/dhth/hours/internal/types\"\n+\t_ \"modernc.org/sqlite\" // sqlite driver\n )\n \n func toggleTracking(db *sql.DB,\n-\ttaskId int,\n+\ttaskID int,\n \tbeginTs time.Time,\n \tendTs time.Time,\n \tcomment string,\n ) tea.Cmd {\n \treturn func() tea.Msg {\n \t\trow := db.QueryRow(`\n SELECT id, task_id\n FROM task_log\n WHERE active=1\n ORDER BY begin_ts DESC\n LIMIT 1\n `)\n \t\tvar trackStatus trackingStatus\n-\t\tvar activeTaskLogId int\n-\t\tvar activeTaskId int\n+\t\tvar activeTaskLogID int\n+\t\tvar activeTaskID int\n \n-\t\terr := row.Scan(\u0026activeTaskLogId, \u0026activeTaskId)\n-\t\tif err == sql.ErrNoRows {\n+\t\terr := row.Scan(\u0026activeTaskLogID, \u0026activeTaskID)\n+\t\tif errors.Is(err, sql.ErrNoRows) {\n \t\t\ttrackStatus = trackingInactive\n \t\t} else if err != nil {\n \t\t\treturn trackingToggledMsg{err: err}\n \t\t} else {\n \t\t\ttrackStatus = trackingActive\n \t\t}\n \n \t\tswitch trackStatus {\n \t\tcase trackingInactive:\n-\t\t\terr = insertNewTLInDB(db, taskId, beginTs)\n+\t\t\t_, err = pers.InsertNewTL(db, taskID, beginTs)\n \t\t\tif err != nil {\n \t\t\t\treturn trackingToggledMsg{err: err}\n \t\t\t} else {\n-\t\t\t\treturn trackingToggledMsg{taskId: taskId}\n+\t\t\t\treturn trackingToggledMsg{taskID: taskID}\n \t\t\t}\n \n \t\tdefault:\n \t\t\tsecsSpent := int(endTs.Sub(beginTs).Seconds())\n-\t\t\terr := updateActiveTLInDB(db, activeTaskLogId, activeTaskId, beginTs, endTs, secsSpent, comment)\n+\t\t\terr := pers.UpdateActiveTL(db, activeTaskLogID, activeTaskID, beginTs, endTs, secsSpent, comment)\n \t\t\tif err != nil {\n \t\t\t\treturn trackingToggledMsg{err: err}\n \t\t\t} else {\n-\t\t\t\treturn trackingToggledMsg{taskId: taskId, finished: true, secsSpent: secsSpent}\n+\t\t\t\treturn trackingToggledMsg{taskID: taskID, finished: true, secsSpent: secsSpent}\n \t\t\t}\n \t\t}\n \t}\n }\n \n func updateTLBeginTS(db *sql.DB, beginTS time.Time) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\terr := updateTLBeginTSInDB(db, beginTS)\n+\t\terr := pers.UpdateTLBeginTS(db, beginTS)\n \t\treturn tlBeginTSUpdatedMsg{beginTS, err}\n \t}\n }\n \n-func insertManualEntry(db *sql.DB, taskId int, beginTS time.Time, endTS time.Time, comment string) tea.Cmd {\n+func insertManualEntry(db *sql.DB, taskID int, beginTS time.Time, endTS time.Time, comment string) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\terr := insertManualTLInDB(db, taskId, beginTS, endTS, comment)\n-\t\treturn manualTaskLogInserted{taskId, err}\n+\t\t_, err := pers.InsertManualTL(db, taskID, beginTS, endTS, comment)\n+\t\treturn manualTaskLogInserted{taskID, err}\n \t}\n }\n \n func fetchActiveTask(db *sql.DB) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\tactiveTaskDetails, err := fetchActiveTaskFromDB(db)\n+\t\tactiveTaskDetails, err := pers.FetchActiveTask(db)\n \t\tif err != nil {\n \t\t\treturn activeTaskFetchedMsg{err: err}\n \t\t}\n \n-\t\tif activeTaskDetails.taskId == -1 {\n+\t\tif activeTaskDetails.TaskID == -1 {\n \t\t\treturn activeTaskFetchedMsg{noneActive: true}\n \t\t}\n \n \t\treturn activeTaskFetchedMsg{\n-\t\t\tactiveTaskId: activeTaskDetails.taskId,\n-\t\t\tbeginTs:      activeTaskDetails.lastLogEntryBeginTs,\n+\t\t\tactiveTaskID: activeTaskDetails.TaskID,\n+\t\t\tbeginTs:      activeTaskDetails.LastLogEntryBeginTS,\n \t\t}\n \t}\n }\n \n-func updateTaskRep(db *sql.DB, t *task) tea.Cmd {\n+func updateTaskRep(db *sql.DB, t *types.Task) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\terr := updateTaskDataFromDB(db, t)\n+\t\terr := pers.UpdateTaskData(db, t)\n \t\treturn taskRepUpdatedMsg{\n \t\t\ttsk: t,\n \t\t\terr: err,\n \t\t}\n \t}\n }\n \n func fetchTaskLogEntries(db *sql.DB) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\tentries, err := fetchTLEntriesFromDB(db, true, 50)\n+\t\tentries, err := pers.FetchTLEntries(db, true, 50)\n \t\treturn taskLogEntriesFetchedMsg{\n \t\t\tentries: entries,\n \t\t\terr:     err,\n \t\t}\n \t}\n }\n \n-func deleteLogEntry(db *sql.DB, entry *taskLogEntry) tea.Cmd {\n+func deleteLogEntry(db *sql.DB, entry *types.TaskLogEntry) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\terr := deleteEntry(db, entry)\n+\t\terr := pers.DeleteTaskLogEntry(db, entry)\n \t\treturn taskLogEntryDeletedMsg{\n \t\t\tentry: entry,\n \t\t\terr:   err,\n \t\t}\n \t}\n }\n \n func deleteActiveTaskLog(db *sql.DB) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\terr := deleteActiveTLInDB(db)\n+\t\terr := pers.DeleteActiveTL(db)\n \t\treturn activeTaskLogDeletedMsg{err}\n \t}\n }\n \n func createTask(db *sql.DB, summary string) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\terr := insertTaskInDB(db, summary)\n+\t\t_, err := pers.InsertTask(db, summary)\n \t\treturn taskCreatedMsg{err}\n \t}\n }\n \n-func updateTask(db *sql.DB, task *task, summary string) tea.Cmd {\n+func updateTask(db *sql.DB, task *types.Task, summary string) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\terr := updateTaskInDB(db, task.id, summary)\n+\t\terr := pers.UpdateTask(db, task.ID, summary)\n \t\treturn taskUpdatedMsg{task, summary, err}\n \t}\n }\n \n-func updateTaskActiveStatus(db *sql.DB, task *task, active bool) tea.Cmd {\n+func updateTaskActiveStatus(db *sql.DB, task *types.Task, active bool) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\terr := updateTaskActiveStatusInDB(db, task.id, active)\n+\t\terr := pers.UpdateTaskActiveStatus(db, task.ID, active)\n \t\treturn taskActiveStatusUpdated{task, active, err}\n \t}\n }\n \n func fetchTasks(db *sql.DB, active bool) tea.Cmd {\n \treturn func() tea.Msg {\n-\t\ttasks, err := fetchTasksFromDB(db, active, 50)\n+\t\ttasks, err := pers.FetchTasks(db, active, 50)\n \t\treturn tasksFetched{tasks, active, err}\n \t}\n }\n \n func hideHelp(interval time.Duration) tea.Cmd {\n \treturn tea.Tick(interval, func(time.Time) tea.Msg {\n \t\treturn HideHelpMsg{}\n \t})\n }\n \n@@ -163,23 +166,23 @@ func getRecordsData(analyticsType recordsType, db *sql.DB, period string, start,\n \treturn func() tea.Msg {\n \t\tvar data string\n \t\tvar err error\n \n \t\tswitch analyticsType {\n \t\tcase reportRecords:\n \t\t\tdata, err = getReport(db, start, numDays, plain)\n \t\tcase reportAggRecords:\n \t\t\tdata, err = getReportAgg(db, start, numDays, plain)\n \t\tcase reportLogs:\n-\t\t\tdata, err = renderTaskLog(db, start, end, 20, plain)\n+\t\t\tdata, err = getTaskLog(db, start, end, 20, plain)\n \t\tcase reportStats:\n-\t\t\tdata, err = renderStats(db, period, start, end, plain)\n+\t\t\tdata, err = getStats(db, period, start, end, plain)\n \t\t}\n \n \t\treturn recordsDataFetchedMsg{\n \t\t\tstart:  start,\n \t\t\tend:    end,\n \t\t\treport: data,\n \t\t\terr:    err,\n \t\t}\n \t}\n }\ndiff --git a/internal/ui/generate.go b/internal/ui/generate.go\nindex 36957cb..8788289 100644\n--- a/internal/ui/generate.go\n+++ b/internal/ui/generate.go\n@@ -1,17 +1,19 @@\n package ui\n \n import (\n \t\"database/sql\"\n \t\"fmt\"\n \t\"math/rand\"\n \t\"time\"\n+\n+\tpers \"github.com/dhth/hours/internal/persistence\"\n )\n \n var (\n \ttasks = []string{\n \t\t\".net\",\n \t\t\"assembly\",\n \t\t\"c\",\n \t\t\"c#\",\n \t\t\"c++\",\n \t\t\"clojure\",\n@@ -85,31 +87,31 @@ var (\n \t\t\"report\",\n \t\t\"script\",\n \t\t\"workflow\",\n \t\t\"log\",\n \t}\n )\n \n func GenerateData(db *sql.DB, numDays, numTasks uint8) error {\n \tfor i := uint8(0); i \u003c numTasks; i++ {\n \t\tsummary := tasks[rand.Intn(len(tasks))]\n-\t\terr := insertTaskInDB(db, summary)\n+\t\t_, err := pers.InsertTask(db, summary)\n \t\tif err != nil {\n \t\t\treturn err\n \t\t}\n \t\tnumLogs := int(numDays/2) + rand.Intn(int(numDays/2))\n \t\tfor j := 0; j \u003c numLogs; j++ {\n \t\t\tbeginTs := randomTimestamp(int(numDays))\n \t\t\tnumMinutes := 30 + rand.Intn(60)\n \t\t\tendTs := beginTs.Add(time.Minute * time.Duration(numMinutes))\n \t\t\tcomment := fmt.Sprintf(\"%s %s\", verbs[rand.Intn(len(verbs))], nouns[rand.Intn(len(nouns))])\n-\t\t\terr = insertManualTLInDB(db, int(i+1), beginTs, endTs, comment)\n+\t\t\t_, err = pers.InsertManualTL(db, int(i+1), beginTs, endTs, comment)\n \t\t\tif err != nil {\n \t\t\t\treturn err\n \t\t\t}\n \t\t}\n \t}\n \n \treturn nil\n }\n \n func randomTimestamp(numDays int) time.Time {\ndiff --git a/internal/ui/initial.go b/internal/ui/initial.go\nindex ab83b95..783a5a6 100644\n--- a/internal/ui/initial.go\n+++ b/internal/ui/initial.go\n@@ -1,22 +1,23 @@\n package ui\n \n import (\n \t\"database/sql\"\n \t\"time\"\n \n \t\"github.com/charmbracelet/bubbles/list\"\n \t\"github.com/charmbracelet/bubbles/textinput\"\n \t\"github.com/charmbracelet/lipgloss\"\n+\t\"github.com/dhth/hours/internal/types\"\n )\n \n-func InitialModel(db *sql.DB) model {\n+func InitialModel(db *sql.DB) Model {\n \tvar activeTaskItems []list.Item\n \tvar inactiveTaskItems []list.Item\n \tvar tasklogListItems []list.Item\n \n \ttrackingInputs := make([]textinput.Model, 3)\n \ttrackingInputs[entryBeginTS] = textinput.New()\n \ttrackingInputs[entryBeginTS].Placeholder = \"09:30\"\n \ttrackingInputs[entryBeginTS].Focus()\n \ttrackingInputs[entryBeginTS].CharLimit = len(string(timeFormat))\n \ttrackingInputs[entryBeginTS].Width = 30\n@@ -33,25 +34,25 @@ func InitialModel(db *sql.DB) model {\n \ttrackingInputs[entryComment].CharLimit = 255\n \ttrackingInputs[entryComment].Width = 80\n \n \ttaskInputs := make([]textinput.Model, 3)\n \ttaskInputs[summaryField] = textinput.New()\n \ttaskInputs[summaryField].Placeholder = \"task summary goes here\"\n \ttaskInputs[summaryField].Focus()\n \ttaskInputs[summaryField].CharLimit = 100\n \ttaskInputs[entryBeginTS].Width = 60\n \n-\tm := model{\n+\tm := Model{\n \t\tdb:                 db,\n \t\tactiveTasksList:    list.New(activeTaskItems, newItemDelegate(lipgloss.Color(activeTaskListColor)), listWidth, 0),\n \t\tinactiveTasksList:  list.New(inactiveTaskItems, newItemDelegate(lipgloss.Color(inactiveTaskListColor)), listWidth, 0),\n-\t\tactiveTaskMap:      make(map[int]*task),\n+\t\tactiveTaskMap:      make(map[int]*types.Task),\n \t\tactiveTaskIndexMap: make(map[int]int),\n \t\ttaskLogList:        list.New(tasklogListItems, newItemDelegate(lipgloss.Color(taskLogListColor)), listWidth, 0),\n \t\tshowHelpIndicator:  true,\n \t\ttrackingInputs:     trackingInputs,\n \t\ttaskInputs:         taskInputs,\n \t}\n \tm.activeTasksList.Title = \"Tasks\"\n \tm.activeTasksList.SetStatusBarItemName(\"task\", \"tasks\")\n \tm.activeTasksList.DisableQuitKeybindings()\n \tm.activeTasksList.SetShowHelp(false)\ndiff --git a/internal/ui/log.go b/internal/ui/log.go\nindex d4fcb7a..50b4de9 100644\n--- a/internal/ui/log.go\n+++ b/internal/ui/log.go\n@@ -1,113 +1,121 @@\n package ui\n \n import (\n \t\"bytes\"\n \t\"database/sql\"\n+\t\"errors\"\n \t\"fmt\"\n \t\"io\"\n-\t\"os\"\n \t\"time\"\n \n \ttea \"github.com/charmbracelet/bubbletea\"\n \t\"github.com/charmbracelet/lipgloss\"\n+\tpers \"github.com/dhth/hours/internal/persistence\"\n+\t\"github.com/dhth/hours/internal/types\"\n+\t\"github.com/dhth/hours/internal/utils\"\n \t\"github.com/olekukonko/tablewriter\"\n )\n \n const (\n-\tlogNumDaysUpperBound = 7\n-\tlogTimeCharsBudget   = 6\n+\tlogNumDaysUpperBound   = 7\n+\tlogTimeCharsBudget     = 6\n+\tinteractiveLogDayLimit = 1\n )\n \n-func RenderTaskLog(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool) {\n+var (\n+\terrInteractiveModeNotApplicable = errors.New(\"interactive mode is not applicable\")\n+\terrCouldntGenerateLogs          = errors.New(\"couldn't generate logs\")\n+)\n+\n+func RenderTaskLog(db *sql.DB, writer io.Writer, plain bool, period string, interactive bool) error {\n \tif period == \"\" {\n-\t\treturn\n+\t\treturn nil\n \t}\n \n-\tts, err := getTimePeriod(period, time.Now(), false)\n+\tts, err := types.GetTimePeriod(period, time.Now(), false)\n \tif err != nil {\n-\t\tfmt.Printf(\"error: %s\\n\", err)\n-\t\tos.Exit(1)\n+\t\treturn err\n \t}\n \n-\tif interactive \u0026\u0026 ts.numDays \u003e 1 {\n-\t\tfmt.Print(\"Interactive mode for logs is limited to a day; use non-interactive mode to see logs for a larger time period\\n\")\n-\t\tos.Exit(1)\n+\tif interactive \u0026\u0026 ts.NumDays \u003e interactiveLogDayLimit {\n+\t\treturn fmt.Errorf(\"%w (limited to %d day); use non-interactive mode to see logs for a larger time period\", errInteractiveModeNotApplicable, interactiveLogDayLimit)\n \t}\n \n-\tlog, err := renderTaskLog(db, ts.start, ts.end, 100, plain)\n+\tlog, err := getTaskLog(db, ts.Start, ts.End, 100, plain)\n \tif err != nil {\n-\t\tfmt.Printf(\"Something went wrong generating the log: %s\\n\", err)\n+\t\treturn fmt.Errorf(\"%w: %s\", errCouldntGenerateLogs, err.Error())\n \t}\n \n \tif interactive {\n-\t\tp := tea.NewProgram(initialRecordsModel(reportLogs, db, ts.start, ts.end, plain, period, ts.numDays, log))\n-\t\tif _, err := p.Run(); err != nil {\n-\t\t\tfmt.Printf(\"Alas, there has been an error: %v\", err)\n-\t\t\tos.Exit(1)\n+\t\tp := tea.NewProgram(initialRecordsModel(reportLogs, db, ts.Start, ts.End, plain, period, ts.NumDays, log))\n+\t\t_, err := p.Run()\n+\t\tif err != nil {\n+\t\t\treturn err\n \t\t}\n \t} else {\n \t\tfmt.Fprint(writer, log)\n \t}\n+\treturn nil\n }\n \n-func renderTaskLog(db *sql.DB, start, end time.Time, limit int, plain bool) (string, error) {\n-\tentries, err := fetchTLEntriesBetweenTSFromDB(db, start, end, limit)\n+func getTaskLog(db *sql.DB, start, end time.Time, limit int, plain bool) (string, error) {\n+\tentries, err := pers.FetchTLEntriesBetweenTS(db, start, end, limit)\n \tif err != nil {\n \t\treturn \"\", err\n \t}\n \n \tvar numEntriesInTable int\n \n \tif len(entries) == 0 {\n \t\tnumEntriesInTable = 1\n \t} else {\n \t\tnumEntriesInTable = len(entries)\n \t}\n \n \tdata := make([][]string, numEntriesInTable)\n \n \tif len(entries) == 0 {\n \t\tdata[0] = []string{\n-\t\t\tRightPadTrim(\"\", 20, false),\n-\t\t\tRightPadTrim(\"\", 40, false),\n-\t\t\tRightPadTrim(\"\", 39, false),\n-\t\t\tRightPadTrim(\"\", logTimeCharsBudget, false),\n+\t\t\tutils.RightPadTrim(\"\", 20, false),\n+\t\t\tutils.RightPadTrim(\"\", 40, false),\n+\t\t\tutils.RightPadTrim(\"\", 39, false),\n+\t\t\tutils.RightPadTrim(\"\", logTimeCharsBudget, false),\n \t\t}\n \t}\n \n \tvar timeSpentStr string\n \n \trs := getReportStyles(plain)\n \tstyleCache := make(map[string]lipgloss.Style)\n \n \tfor i, entry := range entries {\n-\t\ttimeSpentStr = humanizeDuration(entry.secsSpent)\n+\t\ttimeSpentStr = types.HumanizeDuration(entry.SecsSpent)\n \n \t\tif plain {\n \t\t\tdata[i] = []string{\n-\t\t\t\tRightPadTrim(entry.taskSummary, 20, false),\n-\t\t\t\tRightPadTrim(entry.comment, 40, false),\n-\t\t\t\tfmt.Sprintf(\"%s  ...  %s\", entry.beginTs.Format(timeFormat), entry.endTs.Format(timeFormat)),\n-\t\t\t\tRightPadTrim(timeSpentStr, logTimeCharsBudget, false),\n+\t\t\t\tutils.RightPadTrim(entry.TaskSummary, 20, false),\n+\t\t\t\tutils.RightPadTrim(entry.Comment, 40, false),\n+\t\t\t\tfmt.Sprintf(\"%s  ...  %s\", entry.BeginTS.Format(timeFormat), entry.EndTS.Format(timeFormat)),\n+\t\t\t\tutils.RightPadTrim(timeSpentStr, logTimeCharsBudget, false),\n \t\t\t}\n \t\t} else {\n-\t\t\trowStyle, ok := styleCache[entry.taskSummary]\n+\t\t\trowStyle, ok := styleCache[entry.TaskSummary]\n \t\t\tif !ok {\n-\t\t\t\trowStyle = getDynamicStyle(entry.taskSummary)\n-\t\t\t\tstyleCache[entry.taskSummary] = rowStyle\n+\t\t\t\trowStyle = getDynamicStyle(entry.TaskSummary)\n+\t\t\t\tstyleCache[entry.TaskSummary] = rowStyle\n \t\t\t}\n \t\t\tdata[i] = []string{\n-\t\t\t\trowStyle.Render(RightPadTrim(entry.taskSummary, 20, false)),\n-\t\t\t\trowStyle.Render(RightPadTrim(entry.comment, 40, false)),\n-\t\t\t\trowStyle.Render(fmt.Sprintf(\"%s  ...  %s\", entry.beginTs.Format(timeFormat), entry.endTs.Format(timeFormat))),\n-\t\t\t\trowStyle.Render(RightPadTrim(timeSpentStr, logTimeCharsBudget, false)),\n+\t\t\t\trowStyle.Render(utils.RightPadTrim(entry.TaskSummary, 20, false)),\n+\t\t\t\trowStyle.Render(utils.RightPadTrim(entry.Comment, 40, false)),\n+\t\t\t\trowStyle.Render(fmt.Sprintf(\"%s  ...  %s\", entry.BeginTS.Format(timeFormat), entry.EndTS.Format(timeFormat))),\n+\t\t\t\trowStyle.Render(utils.RightPadTrim(timeSpentStr, logTimeCharsBudget, false)),\n \t\t\t}\n \t\t}\n \t}\n \n \tb := bytes.Buffer{}\n \ttable := tablewriter.NewWriter(\u0026b)\n \n \theaderValues := []string{\"Task\", \"Comment\", \"Duration\", \"TimeSpent\"}\n \theaders := make([]string, len(headerValues))\n \tfor i, h := range headerValues {\ndiff --git a/internal/ui/model.go b/internal/ui/model.go\nindex 2279da0..0516e15 100644\n--- a/internal/ui/model.go\n+++ b/internal/ui/model.go\n@@ -1,20 +1,21 @@\n package ui\n \n import (\n \t\"database/sql\"\n \t\"time\"\n \n \t\"github.com/charmbracelet/bubbles/list\"\n \t\"github.com/charmbracelet/bubbles/textinput\"\n \t\"github.com/charmbracelet/bubbles/viewport\"\n \ttea \"github.com/charmbracelet/bubbletea\"\n+\t\"github.com/dhth/hours/internal/types\"\n )\n \n type trackingStatus uint\n \n const (\n \ttrackingInactive trackingStatus = iota\n \ttrackingActive\n )\n \n type dBChange uint\n@@ -75,51 +76,51 @@ const (\n )\n \n const (\n \ttimeFormat         = \"2006/01/02 15:04\"\n \ttimeOnlyFormat     = \"15:04\"\n \tdayFormat          = \"Monday\"\n \tfriendlyTimeFormat = \"Mon, 15:04\"\n \tdateFormat         = \"2006/01/02\"\n )\n \n-type model struct {\n+type Model struct {\n \tactiveView             stateView\n \tlastView               stateView\n \tdb                     *sql.DB\n \tactiveTasksList        list.Model\n \tinactiveTasksList      list.Model\n-\tactiveTaskMap          map[int]*task\n+\tactiveTaskMap          map[int]*types.Task\n \tactiveTaskIndexMap     map[int]int\n \tactiveTLBeginTS        time.Time\n \tactiveTLEndTS          time.Time\n \ttasksFetched           bool\n \ttaskLogList            list.Model\n \ttrackingInputs         []textinput.Model\n \ttrackingFocussedField  timeTrackingFormField\n \ttaskInputs             []textinput.Model\n \ttaskMgmtContext        taskMgmtContext\n \ttaskInputFocussedField taskInputField\n \thelpVP                 viewport.Model\n \thelpVPReady            bool\n \tlastChange             dBChange\n \tchangesLocked          bool\n-\tactiveTaskId           int\n+\tactiveTaskID           int\n \ttasklogSaveType        tasklogSaveType\n \tmessage                string\n \tmessages               []string\n \tshowHelpIndicator      bool\n \tterminalHeight         int\n \ttrackingActive         bool\n }\n \n-func (m model) Init() tea.Cmd {\n+func (m Model) Init() tea.Cmd {\n \treturn tea.Batch(\n \t\thideHelp(time.Minute*1),\n \t\tfetchTasks(m.db, true),\n \t\tfetchTaskLogEntries(m.db),\n \t\tfetchTasks(m.db, false),\n \t)\n }\n \n type recordsModel struct {\n \tdb       *sql.DB\n@@ -128,13 +129,13 @@ type recordsModel struct {\n \tend      time.Time\n \tperiod   string\n \tnumDays  int\n \tplain    bool\n \treport   string\n \tquitting bool\n \tbusy     bool\n \terr      error\n }\n \n-func (m recordsModel) Init() tea.Cmd {\n+func (recordsModel) Init() tea.Cmd {\n \treturn nil\n }\ndiff --git a/internal/ui/msgs.go b/internal/ui/msgs.go\nindex 153d6d7..8d82e95 100644\n--- a/internal/ui/msgs.go\n+++ b/internal/ui/msgs.go\n@@ -1,77 +1,81 @@\n package ui\n \n-import \"time\"\n+import (\n+\t\"time\"\n+\n+\t\"github.com/dhth/hours/internal/types\"\n+)\n \n type HideHelpMsg struct{}\n \n type trackingToggledMsg struct {\n-\ttaskId    int\n+\ttaskID    int\n \tfinished  bool\n \tsecsSpent int\n \terr       error\n }\n \n type taskRepUpdatedMsg struct {\n-\ttsk *task\n+\ttsk *types.Task\n \terr error\n }\n \n type manualTaskLogInserted struct {\n-\ttaskId int\n+\ttaskID int\n \terr    error\n }\n \n type tlBeginTSUpdatedMsg struct {\n \tbeginTS time.Time\n \terr     error\n }\n \n type activeTaskLogDeletedMsg struct {\n \terr error\n }\n \n type activeTaskFetchedMsg struct {\n-\tactiveTaskId int\n+\tactiveTaskID int\n \tbeginTs      time.Time\n \tnoneActive   bool\n \terr          error\n }\n \n type taskLogEntriesFetchedMsg struct {\n-\tentries []taskLogEntry\n+\tentries []types.TaskLogEntry\n \terr     error\n }\n \n type taskCreatedMsg struct {\n \terr error\n }\n \n type taskUpdatedMsg struct {\n-\ttsk     *task\n+\ttsk     *types.Task\n \tsummary string\n \terr     error\n }\n \n type taskActiveStatusUpdated struct {\n-\ttsk    *task\n+\ttsk    *types.Task\n \tactive bool\n \terr    error\n }\n \n type taskLogEntryDeletedMsg struct {\n-\tentry *taskLogEntry\n+\tentry *types.TaskLogEntry\n \terr   error\n }\n \n type tasksFetched struct {\n-\ttasks  []task\n+\ttasks  []types.Task\n \tactive bool\n \terr    error\n }\n \n type recordsDataFetchedMsg struct {\n \tstart  time.Time\n \tend    time.Time\n \treport string\n \terr    error\n }\ndiff --git a/internal/ui/queries.go b/internal/ui/queries.go\ndeleted file mode 100644\nindex 49d5909..0000000\n--- a/internal/ui/queries.go\n+++ /dev/null\n@@ -1,522 +0,0 @@\n-package ui\n-\n-import (\n-\t\"database/sql\"\n-\t\"errors\"\n-\t\"fmt\"\n-\t\"time\"\n-)\n-\n-func insertNewTLInDB(db *sql.DB, taskId int, beginTs time.Time) error {\n-\tstmt, err := db.Prepare(`\n-INSERT INTO task_log (task_id, begin_ts, active)\n-VALUES (?, ?, ?);\n-`)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer stmt.Close()\n-\n-\t_, err = stmt.Exec(taskId, beginTs.UTC(), true)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\treturn nil\n-}\n-\n-func updateTLBeginTSInDB(db *sql.DB, beginTs time.Time) error {\n-\tstmt, err := db.Prepare(`\n-UPDATE task_log SET begin_ts=?\n-WHERE active is true;\n-`)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer stmt.Close()\n-\n-\t_, err = stmt.Exec(beginTs.UTC(), true)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\treturn nil\n-}\n-\n-func deleteActiveTLInDB(db *sql.DB) error {\n-\tstmt, err := db.Prepare(`\n-DELETE FROM task_log\n-WHERE active=true;\n-`)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer stmt.Close()\n-\n-\t_, err = stmt.Exec()\n-\n-\treturn err\n-}\n-\n-func updateActiveTLInDB(db *sql.DB, taskLogId int, taskId int, beginTs, endTs time.Time, secsSpent int, comment string) error {\n-\ttx, err := db.Begin()\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer func() {\n-\t\t_ = tx.Rollback()\n-\t}()\n-\n-\tstmt, err := tx.Prepare(`\n-UPDATE task_log\n-SET active = 0,\n-    begin_ts = ?,\n-    end_ts = ?,\n-    secs_spent = ?,\n-    comment = ?\n-WHERE id = ?\n-AND active = 1;\n-`)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer stmt.Close()\n-\n-\t_, err = stmt.Exec(beginTs.UTC(), endTs.UTC(), secsSpent, comment, taskLogId)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\ttStmt, err := tx.Prepare(`\n-UPDATE task\n-SET secs_spent = secs_spent+?,\n-    updated_at = ?\n-WHERE id = ?;\n-    `)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer tStmt.Close()\n-\n-\t_, err = tStmt.Exec(secsSpent, time.Now().UTC(), taskId)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\terr = tx.Commit()\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\treturn nil\n-}\n-\n-func insertManualTLInDB(db *sql.DB, taskId int, beginTs time.Time, endTs time.Time, comment string) error {\n-\tsecsSpent := int(endTs.Sub(beginTs).Seconds())\n-\ttx, err := db.Begin()\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer func() {\n-\t\t_ = tx.Rollback()\n-\t}()\n-\n-\tstmt, err := tx.Prepare(`\n-INSERT INTO task_log (task_id, begin_ts, end_ts, secs_spent, comment, active)\n-VALUES (?, ?, ?, ?, ?, ?);\n-`)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer stmt.Close()\n-\n-\t_, err = stmt.Exec(taskId, beginTs.UTC(), endTs.UTC(), secsSpent, comment, false)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\ttStmt, err := tx.Prepare(`\n-UPDATE task\n-SET secs_spent = secs_spent+?,\n-    updated_at = ?\n-WHERE id = ?;\n-    `)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer tStmt.Close()\n-\n-\t_, err = tStmt.Exec(secsSpent, time.Now().UTC(), taskId)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\terr = tx.Commit()\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\treturn nil\n-}\n-\n-func fetchActiveTaskFromDB(db *sql.DB) (activeTaskDetails, error) {\n-\trow := db.QueryRow(`\n-SELECT t.id, t.summary, tl.begin_ts\n-FROM task_log tl left join task t on tl.task_id = t.id\n-WHERE tl.active=true;\n-`)\n-\n-\tvar activeTaskDetails activeTaskDetails\n-\terr := row.Scan(\n-\t\t\u0026activeTaskDetails.taskId,\n-\t\t\u0026activeTaskDetails.taskSummary,\n-\t\t\u0026activeTaskDetails.lastLogEntryBeginTs,\n-\t)\n-\tif errors.Is(err, sql.ErrNoRows) {\n-\t\tactiveTaskDetails.taskId = -1\n-\t\treturn activeTaskDetails, nil\n-\t} else if err != nil {\n-\t\treturn activeTaskDetails, err\n-\t}\n-\tactiveTaskDetails.lastLogEntryBeginTs = activeTaskDetails.lastLogEntryBeginTs.Local()\n-\treturn activeTaskDetails, nil\n-}\n-\n-func insertTaskInDB(db *sql.DB, summary string) error {\n-\tstmt, err := db.Prepare(`\n-INSERT into task (summary, active, created_at, updated_at)\n-VALUES (?, true, ?, ?);\n-`)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer stmt.Close()\n-\n-\tnow := time.Now().UTC()\n-\t_, err = stmt.Exec(summary, now, now)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\treturn nil\n-}\n-\n-func updateTaskInDB(db *sql.DB, id int, summary string) error {\n-\tstmt, err := db.Prepare(`\n-UPDATE task\n-SET summary = ?,\n-    updated_at = ?\n-WHERE id = ?\n-`)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer stmt.Close()\n-\n-\t_, err = stmt.Exec(summary, time.Now().UTC(), id)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\treturn nil\n-}\n-\n-func updateTaskActiveStatusInDB(db *sql.DB, id int, active bool) error {\n-\tstmt, err := db.Prepare(`\n-UPDATE task\n-SET active = ?,\n-    updated_at = ?\n-WHERE id = ?\n-`)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer stmt.Close()\n-\n-\t_, err = stmt.Exec(active, time.Now().UTC(), id)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\treturn nil\n-}\n-\n-func updateTaskDataFromDB(db *sql.DB, t *task) error {\n-\trow := db.QueryRow(`\n-SELECT secs_spent, updated_at\n-FROM task\n-WHERE id=?;\n-    `, t.id)\n-\n-\terr := row.Scan(\n-\t\t\u0026t.secsSpent,\n-\t\t\u0026t.updatedAt,\n-\t)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\treturn nil\n-}\n-\n-func fetchTasksFromDB(db *sql.DB, active bool, limit int) ([]task, error) {\n-\tvar tasks []task\n-\n-\trows, err := db.Query(`\n-SELECT id, summary, secs_spent, created_at, updated_at, active\n-FROM task\n-WHERE active=?\n-ORDER by updated_at DESC\n-LIMIT ?;\n-    `, active, limit)\n-\tif err != nil {\n-\t\treturn nil, err\n-\t}\n-\tdefer rows.Close()\n-\n-\tfor rows.Next() {\n-\t\tvar entry task\n-\t\terr = rows.Scan(\u0026entry.id,\n-\t\t\t\u0026entry.summary,\n-\t\t\t\u0026entry.secsSpent,\n-\t\t\t\u0026entry.createdAt,\n-\t\t\t\u0026entry.updatedAt,\n-\t\t\t\u0026entry.active,\n-\t\t)\n-\t\tif err != nil {\n-\t\t\treturn nil, err\n-\t\t}\n-\t\tentry.createdAt = entry.createdAt.Local()\n-\t\tentry.updatedAt = entry.updatedAt.Local()\n-\t\ttasks = append(tasks, entry)\n-\n-\t}\n-\treturn tasks, nil\n-}\n-\n-func fetchTLEntriesFromDB(db *sql.DB, desc bool, limit int) ([]taskLogEntry, error) {\n-\tvar logEntries []taskLogEntry\n-\n-\tvar order string\n-\tif desc {\n-\t\torder = \"DESC\"\n-\t} else {\n-\t\torder = \"ASC\"\n-\t}\n-\tquery := fmt.Sprintf(`\n-SELECT tl.id, tl.task_id, t.summary, tl.begin_ts, tl.end_ts, tl.secs_spent, tl.comment\n-FROM task_log tl left join task t on tl.task_id=t.id\n-WHERE tl.active=false\n-ORDER by tl.begin_ts %s\n-LIMIT ?;\n-`, order)\n-\n-\trows, err := db.Query(query, limit)\n-\tif err != nil {\n-\t\treturn nil, err\n-\t}\n-\tdefer rows.Close()\n-\n-\tfor rows.Next() {\n-\t\tvar entry taskLogEntry\n-\t\terr = rows.Scan(\u0026entry.id,\n-\t\t\t\u0026entry.taskId,\n-\t\t\t\u0026entry.taskSummary,\n-\t\t\t\u0026entry.beginTs,\n-\t\t\t\u0026entry.endTs,\n-\t\t\t\u0026entry.secsSpent,\n-\t\t\t\u0026entry.comment,\n-\t\t)\n-\t\tif err != nil {\n-\t\t\treturn nil, err\n-\t\t}\n-\t\tentry.beginTs = entry.beginTs.Local()\n-\t\tentry.endTs = entry.endTs.Local()\n-\t\tlogEntries = append(logEntries, entry)\n-\n-\t}\n-\treturn logEntries, nil\n-}\n-\n-func fetchTLEntriesBetweenTSFromDB(db *sql.DB, beginTs, endTs time.Time, limit int) ([]taskLogEntry, error) {\n-\tvar logEntries []taskLogEntry\n-\n-\trows, err := db.Query(`\n-SELECT tl.id, tl.task_id, t.summary, tl.begin_ts, tl.end_ts, tl.secs_spent, tl.comment\n-FROM task_log tl left join task t on tl.task_id=t.id\n-WHERE tl.active=false\n-AND tl.end_ts \u003e= ?\n-AND tl.end_ts \u003c ?\n-ORDER by tl.begin_ts ASC LIMIT ?;\n-    `, beginTs.UTC(), endTs.UTC(), limit)\n-\tif err != nil {\n-\t\treturn nil, err\n-\t}\n-\tdefer rows.Close()\n-\n-\tfor rows.Next() {\n-\t\tvar entry taskLogEntry\n-\t\terr = rows.Scan(\u0026entry.id,\n-\t\t\t\u0026entry.taskId,\n-\t\t\t\u0026entry.taskSummary,\n-\t\t\t\u0026entry.beginTs,\n-\t\t\t\u0026entry.endTs,\n-\t\t\t\u0026entry.secsSpent,\n-\t\t\t\u0026entry.comment,\n-\t\t)\n-\t\tif err != nil {\n-\t\t\treturn nil, err\n-\t\t}\n-\t\tentry.beginTs = entry.beginTs.Local()\n-\t\tentry.endTs = entry.endTs.Local()\n-\t\tlogEntries = append(logEntries, entry)\n-\n-\t}\n-\treturn logEntries, nil\n-}\n-\n-func fetchStatsFromDB(db *sql.DB, limit int) ([]taskReportEntry, error) {\n-\trows, err := db.Query(`\n-SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries, t.secs_spent\n-from task_log tl\n-LEFT JOIN task t on tl.task_id = t.id\n-GROUP BY tl.task_id\n-ORDER BY t.secs_spent DESC\n-limit ?;\n-`, limit)\n-\tif err != nil {\n-\t\treturn nil, err\n-\t}\n-\tdefer rows.Close()\n-\n-\tvar tLE []taskReportEntry\n-\n-\tfor rows.Next() {\n-\t\tvar entry taskReportEntry\n-\t\terr = rows.Scan(\n-\t\t\t\u0026entry.taskId,\n-\t\t\t\u0026entry.taskSummary,\n-\t\t\t\u0026entry.numEntries,\n-\t\t\t\u0026entry.secsSpent,\n-\t\t)\n-\t\tif err != nil {\n-\t\t\treturn nil, err\n-\t\t}\n-\t\ttLE = append(tLE, entry)\n-\n-\t}\n-\treturn tLE, nil\n-}\n-\n-func fetchStatsBetweenTSFromDB(db *sql.DB, beginTs, endTs time.Time, limit int) ([]taskReportEntry, error) {\n-\trows, err := db.Query(`\n-SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries,  SUM(tl.secs_spent) AS secs_spent\n-FROM task_log tl \n-LEFT JOIN task t ON tl.task_id = t.id\n-WHERE tl.end_ts \u003e= ? AND tl.end_ts \u003c ?\n-GROUP BY tl.task_id\n-ORDER BY secs_spent DESC\n-LIMIT ?;\n-`, beginTs.UTC(), endTs.UTC(), limit)\n-\tif err != nil {\n-\t\treturn nil, err\n-\t}\n-\tdefer rows.Close()\n-\n-\tvar tLE []taskReportEntry\n-\n-\tfor rows.Next() {\n-\t\tvar entry taskReportEntry\n-\t\terr = rows.Scan(\n-\t\t\t\u0026entry.taskId,\n-\t\t\t\u0026entry.taskSummary,\n-\t\t\t\u0026entry.numEntries,\n-\t\t\t\u0026entry.secsSpent,\n-\t\t)\n-\t\tif err != nil {\n-\t\t\treturn nil, err\n-\t\t}\n-\t\ttLE = append(tLE, entry)\n-\n-\t}\n-\treturn tLE, nil\n-}\n-\n-func fetchReportBetweenTSFromDB(db *sql.DB, beginTs, endTs time.Time, limit int) ([]taskReportEntry, error) {\n-\trows, err := db.Query(`\n-SELECT tl.task_id, t.summary, COUNT(tl.id) as num_entries,  SUM(tl.secs_spent) AS secs_spent\n-FROM task_log tl \n-LEFT JOIN task t ON tl.task_id = t.id\n-WHERE tl.end_ts \u003e= ? AND tl.end_ts \u003c ?\n-GROUP BY tl.task_id\n-ORDER BY t.updated_at ASC\n-LIMIT ?;\n-`, beginTs.UTC(), endTs.UTC(), limit)\n-\tif err != nil {\n-\t\treturn nil, err\n-\t}\n-\tdefer rows.Close()\n-\n-\tvar tLE []taskReportEntry\n-\n-\tfor rows.Next() {\n-\t\tvar entry taskReportEntry\n-\t\terr = rows.Scan(\n-\t\t\t\u0026entry.taskId,\n-\t\t\t\u0026entry.taskSummary,\n-\t\t\t\u0026entry.numEntries,\n-\t\t\t\u0026entry.secsSpent,\n-\t\t)\n-\t\tif err != nil {\n-\t\t\treturn nil, err\n-\t\t}\n-\t\ttLE = append(tLE, entry)\n-\n-\t}\n-\treturn tLE, nil\n-}\n-\n-func deleteEntry(db *sql.DB, entry *taskLogEntry) error {\n-\tsecsSpent := entry.secsSpent\n-\n-\ttx, err := db.Begin()\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer func() {\n-\t\t_ = tx.Rollback()\n-\t}()\n-\n-\tstmt, err := tx.Prepare(`\n-DELETE from task_log\n-WHERE ID=?;\n-`)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer stmt.Close()\n-\n-\t_, err = stmt.Exec(entry.id)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\ttStmt, err := tx.Prepare(`\n-UPDATE task\n-SET secs_spent = secs_spent-?,\n-    updated_at = ?\n-WHERE id = ?;\n-    `)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\tdefer tStmt.Close()\n-\n-\t_, err = tStmt.Exec(secsSpent, time.Now().UTC(), entry.taskId)\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\terr = tx.Commit()\n-\tif err != nil {\n-\t\treturn err\n-\t}\n-\n-\treturn nil\n-}\ndiff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go\ndeleted file mode 100644\nindex b8cbfef..0000000\n--- a/internal/ui/render_helpers.go\n+++ /dev/null\n@@ -1,60 +0,0 @@\n-package ui\n-\n-import (\n-\t\"fmt\"\n-\t\"time\"\n-\n-\t\"github.com/dustin/go-humanize\"\n-)\n-\n-func (t *task) updateTitle() {\n-\tvar trackingIndicator string\n-\tif t.trackingActive {\n-\t\ttrackingIndicator = \"⏲ \"\n-\t}\n-\n-\tt.title = trackingIndicator + t.summary\n-}\n-\n-func (t *task) updateDesc() {\n-\t","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdhth%2Fdstlled-diff-action","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdhth%2Fdstlled-diff-action","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdhth%2Fdstlled-diff-action/lists"}