├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── vulncheck.yml │ ├── scan.yml │ ├── release.yml │ ├── pr.yml │ └── main.yml └── dependabot.yml ├── yamlfmt.yml ├── .gitignore ├── internal ├── common │ └── common.go ├── ui │ ├── theme │ │ ├── testdata │ │ │ ├── malformed-json.json │ │ │ ├── valid-with-partial-config.json │ │ │ ├── invalid-schema.json │ │ │ ├── invalid-data.json │ │ │ ├── invalid-with-entire-config.json │ │ │ └── valid-with-entire-config.json │ │ ├── tokyonight.go │ │ ├── dracula.go │ │ ├── nightowl.go │ │ ├── github_dark.go │ │ ├── xcode_dark.go │ │ ├── gruvbox_dark.go │ │ ├── monokai_classic.go │ │ ├── catppuccin_mocha.go │ │ ├── __snapshots__ │ │ │ ├── TestLoadCustomLoadsFullThemeCorrectly_1.snap.yaml │ │ │ └── TestLoadCustomPartialThemeCorrectly_1.snap.yaml │ │ ├── builtin.go │ │ ├── theme_test.go │ │ └── theme.go │ ├── __snapshots__ │ │ ├── TestInsufficientDimensionsView_1.snap │ │ ├── TestEmptyInactiveTaskListView_1.snap │ │ ├── TestTaskLogViewEmpty_1.snap │ │ ├── TestTaskListViewEmpty_1.snap │ │ ├── TestTaskListViewWithErrorMessage_1.snap │ │ ├── TestTaskListViewDebugMode_1.snap │ │ ├── TestTaskListViewWithTasks_1.snap │ │ ├── TestTaskListViewWithInfoContext_1.snap │ │ ├── TestInactiveTaskListViewWithTasks_1.snap │ │ ├── TestEditActiveTLView_1.snap │ │ ├── TestTerminalWidthResizingWorks_1.snap │ │ ├── TestCreateTaskView_1.snap │ │ ├── TestUpdateTaskView_1.snap │ │ ├── TestCreateTaskViewWithNoInput_1.snap │ │ ├── TestEditSavedTLView_1.snap │ │ ├── TestFinishActiveTLView_1.snap │ │ ├── TestManualTasklogEntryView_1.snap │ │ ├── TestFinishActiveTLViewWhereNoTimeTracked_1.snap │ │ ├── TestFinishActiveTLViewWithWarningContext_1.snap │ │ ├── TestFinishActiveTLViewWhereEndTimeBeforeBeginTime_1.snap │ │ ├── TestHelpView_1.snap │ │ └── TestTaskLogViewWithEntries_1.snap │ ├── errors.go │ ├── task_delegate.go │ ├── styles_test.go │ ├── active.go │ ├── ui.go │ ├── msgs.go │ ├── generate.go │ ├── help.go │ ├── stats.go │ ├── log.go │ ├── initial.go │ ├── model.go │ ├── cmds.go │ └── styles.go ├── persistence │ ├── open.go │ ├── migrations_test.go │ ├── init.go │ └── migrations.go ├── types │ ├── types_test.go │ ├── duration.go │ ├── duration_test.go │ ├── date_helpers.go │ ├── types.go │ └── date_helpers_test.go └── utils │ ├── utils.go │ └── utils_test.go ├── main.go ├── cmd ├── utils.go ├── static │ ├── show-theme-config-examples.txt │ └── sample-theme.txt ├── utils_test.go ├── errors.go └── themes.go ├── tests ├── cli │ ├── themes │ │ ├── __snapshots__ │ │ │ ├── TestShowConfig_fails_for_incorrect_builtin_theme_1.snap │ │ │ ├── TestShowConfig_fails_for_incorrect_builtin_theme_provided_via_env_var_1.snap │ │ │ ├── TestShowConfig_help_flag_works_1.snap │ │ │ └── TestShowConfig_works_for_built-in_theme_1.snap │ │ └── show_config_test.go │ └── fixture.go └── test.sh ├── .goreleaser.yml ├── LICENSE ├── .golangci.yml ├── CHANGELOG.md └── go.mod /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /yamlfmt.yml: -------------------------------------------------------------------------------- 1 | formatter: 2 | retain_line_breaks_single: true 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | cosign.key 3 | cosign.pub 4 | hours 5 | debug.log 6 | .quickrun 7 | justfile 8 | .cmds 9 | .frames 10 | -------------------------------------------------------------------------------- /internal/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | const ( 4 | Author = "@dhth" 5 | RepoIssuesURL = "https://github.com/dhth/hours/issues" 6 | ) 7 | -------------------------------------------------------------------------------- /internal/ui/theme/testdata/malformed-json.json: -------------------------------------------------------------------------------- 1 | { 2 | "activeTask": "#8ec07c", 3 | "activeTaskBeginTime": "#d3869b", 4 | "activeTasks": "#fe8019", 5 | "formContext": "#fabd2f", 6 | } 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/dhth/hours/cmd" 7 | ) 8 | 9 | func main() { 10 | err := cmd.Execute() 11 | if err != nil { 12 | os.Exit(1) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestInsufficientDimensionsView_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Terminal size too small: 3 | Width = 50 Height = 20 4 | 5 | Minimum dimensions needed: 6 | Width = 80 Height = 32 7 | 8 | Press q// 9 | to exit 10 | -------------------------------------------------------------------------------- /internal/persistence/open.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | func GetDB(dbpath string) (*sql.DB, error) { 8 | db, err := sql.Open("sqlite", dbpath) 9 | db.SetMaxOpenConns(1) 10 | db.SetMaxIdleConns(1) 11 | return db, err 12 | } 13 | -------------------------------------------------------------------------------- /internal/ui/theme/testdata/valid-with-partial-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "activeTask": "#8ec07c", 3 | "activeTaskBeginTime": "#d3869b", 4 | "activeTasks": "#fe8019", 5 | "formContext": "#fabd2f", 6 | "formFieldName": "#8ec07c", 7 | "formHelp": "#928374", 8 | "helpMsg": "#83a598" 9 | } 10 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | ) 7 | 8 | func expandTilde(path string, homeDir string) string { 9 | pathWithoutTilde, found := strings.CutPrefix(path, "~/") 10 | if !found { 11 | return path 12 | } 13 | return filepath.Join(homeDir, pathWithoutTilde) 14 | } 15 | -------------------------------------------------------------------------------- /internal/ui/errors.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | errInteractiveModeNotApplicable = errors.New("interactive mode is not applicable") 9 | errCouldntAddDataToTable = errors.New("couldn't add data to table") 10 | errCouldntRenderTable = errors.New("couldn't render table") 11 | ) 12 | -------------------------------------------------------------------------------- /tests/cli/themes/__snapshots__/TestShowConfig_fails_for_incorrect_builtin_theme_1.snap: -------------------------------------------------------------------------------- 1 | success: false 2 | exit_code: 1 3 | ----- stdout ----- 4 | 5 | ----- stderr ----- 6 | Error: built-in theme doesn't exist: "unknown" 7 | 8 | If you intended to use a custom theme, prefix it with "custom:". Run "hours themes list" to list all themes. 9 | 10 | -------------------------------------------------------------------------------- /tests/cli/themes/__snapshots__/TestShowConfig_fails_for_incorrect_builtin_theme_provided_via_env_var_1.snap: -------------------------------------------------------------------------------- 1 | success: false 2 | exit_code: 1 3 | ----- stdout ----- 4 | 5 | ----- stderr ----- 6 | Error: built-in theme doesn't exist: "unknown" 7 | 8 | If you intended to use a custom theme, prefix it with "custom:". Run "hours themes list" to list all themes. 9 | 10 | -------------------------------------------------------------------------------- /internal/ui/theme/testdata/invalid-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "activeTask": "#8ec07c", 3 | "activeTaskBeginTime": 1, 4 | "activeTasks": "#fe8019", 5 | "formContext": "#fabd2f", 6 | "formFieldName": "#8ec07c", 7 | "formHelp": "#928374", 8 | "helpMsg": "#83a598", 9 | "helpPrimary": "#83a598", 10 | "taskLogDetails": true, 11 | "taskEntry": "#8ec07c", 12 | "taskLogEntry": "#fabd2f", 13 | "taskLogList": "#b8bb26" 14 | } 15 | -------------------------------------------------------------------------------- /cmd/static/show-theme-config-examples.txt: -------------------------------------------------------------------------------- 1 | hours themes show-config # shows config for the current theme 2 | hours themes show-config -t 'default' # shows config for the default theme 3 | hours themes show-config -t 'monokai-classic' # shows config for the built-in theme "monokai-classic" 4 | hours themes show-config -t 'custom:yourtheme' # shows config for a user-defined theme, with missing elements replaced by those from the default theme 5 | -------------------------------------------------------------------------------- /internal/ui/theme/testdata/invalid-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "activeTask": "!! not a color !!", 3 | "activeTaskBeginTime": "#d3869b", 4 | "activeTasks": "#fe8019", 5 | "formContext": "#fabd2f", 6 | "formFieldName": "0", 7 | "formHelp": "928374", 8 | "helpMsg": "#83a598", 9 | "helpPrimary": "-1", 10 | "helpSecondary": "#bdae93", 11 | "taskLogDetails": "#d3869b", 12 | "taskEntry": "256", 13 | "taskLogEntry": "#fabd2f", 14 | "taskLogList": "255", 15 | "tasks": [ 16 | "#d3869b", 17 | "!! not a color !!" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/vulncheck.yml: -------------------------------------------------------------------------------- 1 | name: vulncheck 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 2 * * 2,6' 7 | 8 | jobs: 9 | vulncheck: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - name: Set up Go 14 | uses: actions/setup-go@v6 15 | with: 16 | go-version-file: 'go.mod' 17 | - name: install govulncheck 18 | run: go install golang.org/x/vuln/cmd/govulncheck@latest 19 | - name: govulncheck 20 | run: govulncheck ./... 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | patch-updates: 9 | update-types: ["patch"] 10 | minor-updates: 11 | update-types: ["minor"] 12 | labels: 13 | - "dependencies" 14 | commit-message: 15 | prefix: "build" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "monthly" 20 | labels: 21 | - "dependencies" 22 | commit-message: 23 | prefix: "ci" 24 | -------------------------------------------------------------------------------- /internal/ui/task_delegate.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/list" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | func newItemDelegate(titleColor, descColor, selectedColor lipgloss.Color) list.DefaultDelegate { 9 | d := list.NewDefaultDelegate() 10 | 11 | d.Styles.NormalTitle = d.Styles. 12 | NormalTitle. 13 | Foreground(titleColor) 14 | 15 | d.Styles.NormalDesc = d.Styles. 16 | NormalDesc. 17 | Foreground(descColor) 18 | 19 | d.Styles.SelectedTitle = d.Styles. 20 | SelectedTitle. 21 | Foreground(selectedColor). 22 | BorderLeftForeground(selectedColor) 23 | 24 | d.Styles.SelectedDesc = d.Styles. 25 | SelectedTitle 26 | 27 | return d 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for hours 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /internal/ui/theme/tokyonight.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | const themeNameTokyonight = "tokyonight" 4 | 5 | func paletteTokyonight() builtInThemePalette { 6 | return builtInThemePalette{ 7 | primary: "#f7768e", 8 | secondary: "#7aa2f7", 9 | tertiary: "#9ece6a", 10 | quaternary: "#bb9af7", 11 | foreground: "#1a1b26", 12 | text: "#7aa2f7", 13 | subtext: "#c0caf5", 14 | muted: "#a9b1d6", 15 | help: "#e0af68", 16 | info: "#9ece6a", 17 | error: "#f7768e", 18 | warn: "#e0af68", 19 | tasks: []string{ 20 | "#2ac3de", 21 | "#41a6b5", 22 | "#73daca", 23 | "#7dcfff", 24 | "#b4f9f8", 25 | "#d4a5e5", 26 | "#e06c75", 27 | "#ff9e64", 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/ui/theme/dracula.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | const ( 4 | themeNameDracula = "dracula" 5 | ) 6 | 7 | func paletteDracula() builtInThemePalette { 8 | return builtInThemePalette{ 9 | primary: "#ff6e6e", 10 | secondary: "#50fa7b", 11 | tertiary: "#bd93f9", 12 | quaternary: "#8be9fd", 13 | foreground: "#282a36", 14 | text: "#ffffff", 15 | subtext: "#f8f8f2", 16 | muted: "#bd93f9", 17 | help: "#f1fa8c", 18 | info: "#69ff94", 19 | error: "#ff5555", 20 | warn: "#ffffa5", 21 | tasks: []string{ 22 | "#6ecbff", 23 | "#7be0ad", 24 | "#a4ffff", 25 | "#c9a0dc", 26 | "#e8a0e8", 27 | "#ff79c6", 28 | "#ff92df", 29 | "#ffb86c", 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/ui/theme/nightowl.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | const ( 4 | themeNameNightOwl = "night-owl" 5 | ) 6 | 7 | func paletteNightOwl() builtInThemePalette { 8 | return builtInThemePalette{ 9 | primary: "#22da6e", 10 | secondary: "#82aaff", 11 | tertiary: "#c792ea", 12 | quaternary: "#ef5350", 13 | foreground: "#011627", 14 | text: "#ffffff", 15 | subtext: "#d6deeb", 16 | muted: "#d6deeb", 17 | help: "#ffeb95", 18 | info: "#22da6e", 19 | error: "#ef5350", 20 | warn: "#c792ea", 21 | tasks: []string{ 22 | "#7fdbca", 23 | "#80cbc4", 24 | "#a3c4f3", 25 | "#b8e994", 26 | "#dbb2ff", 27 | "#ecc48d", 28 | "#f78c6c", 29 | "#ff9eb5", 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/ui/theme/github_dark.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | const ( 4 | themeNameGithubDark = "github-dark" 5 | ) 6 | 7 | func paletteGithubDark() builtInThemePalette { 8 | return builtInThemePalette{ 9 | primary: "#f78166", 10 | secondary: "#56d364", 11 | tertiary: "#db61a2", 12 | quaternary: "#6ca4f8", 13 | foreground: "#101216", 14 | text: "#ffffff", 15 | subtext: "#8b949e", 16 | muted: "#8b949e", 17 | help: "#e3b341", 18 | info: "#56d364", 19 | error: "#db61a2", 20 | warn: "#f78166", 21 | tasks: []string{ 22 | "#79c0ff", 23 | "#7ee787", 24 | "#89dceb", 25 | "#a5d6ff", 26 | "#d2a8ff", 27 | "#ff9bce", 28 | "#ffa657", 29 | "#ffd33d", 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/ui/theme/xcode_dark.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | const ( 4 | themeNameXcodeDark = "xcode-dark" 5 | ) 6 | 7 | func paletteXcodeDark() builtInThemePalette { 8 | return builtInThemePalette{ 9 | primary: "#ff7ab2", 10 | secondary: "#4eb0cc", 11 | tertiary: "#ff8170", 12 | quaternary: "#b281eb", 13 | foreground: "#292a30", 14 | text: "#dfdfe0", 15 | subtext: "#7f8c98", 16 | muted: "#7f8c98", 17 | help: "#d9c97c", 18 | info: "#78c2b3", 19 | error: "#ff7ab2", 20 | warn: "#ffa14f", 21 | tasks: []string{ 22 | "#6bdfff", 23 | "#83d9a2", 24 | "#a8c8ff", 25 | "#acf2e4", 26 | "#d0a8ff", 27 | "#ff9cac", 28 | "#ffc1a6", 29 | "#ffcc66", 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/ui/theme/gruvbox_dark.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | const ( 4 | themeNameGruvboxDark = "gruvbox-dark" 5 | ) 6 | 7 | func paletteGruvboxDark() builtInThemePalette { 8 | return builtInThemePalette{ 9 | primary: "#fe8019", 10 | secondary: "#83a598", 11 | tertiary: "#b8bb26", 12 | quaternary: "#d3869b", 13 | foreground: "#282828", 14 | text: "#ebdbb2", 15 | subtext: "#a89984", 16 | muted: "#928374", 17 | help: "#fabd2f", 18 | info: "#8ec07c", 19 | error: "#fb4934", 20 | warn: "#d79921", 21 | tasks: []string{ 22 | "#7ec8e3", 23 | "#98d4bb", 24 | "#a3d9a5", 25 | "#c9b1ff", 26 | "#d5c4a1", 27 | "#e09f9f", 28 | "#f4a261", 29 | "#f5d67b", 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/ui/theme/monokai_classic.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | const themeNameMonokaiClassic = "monokai-classic" 4 | 5 | func paletteMonokaiClassic() builtInThemePalette { 6 | return builtInThemePalette{ 7 | primary: "#66d9ef", 8 | secondary: "#ae81ff", 9 | tertiary: "#a6e22e", 10 | quaternary: "#fd971f", 11 | foreground: "#272822", 12 | text: "#fdfff1", 13 | subtext: "#c0c1b5", 14 | muted: "#57584f", 15 | help: "#e6db74", 16 | info: "#a6e22e", 17 | error: "#f92672", 18 | warn: "#fd971f", 19 | tasks: []string{ 20 | "#78dce8", 21 | "#a9dc76", 22 | "#ab9df2", 23 | "#c4e88a", 24 | "#d4bfff", 25 | "#ff6a9e", 26 | "#ffb86c", 27 | "#ffd866", 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/ui/theme/catppuccin_mocha.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | const ( 4 | themeNameCatppuccinMocha = "catppuccin-mocha" 5 | ) 6 | 7 | func paletteCatppuccinMocha() builtInThemePalette { 8 | return builtInThemePalette{ 9 | primary: "#f37799", 10 | secondary: "#74a8fc", 11 | tertiary: "#a6e3a1", 12 | quaternary: "#f2aede", 13 | foreground: "#1e1e2e", 14 | text: "#74a8fc", 15 | subtext: "#a6adc8", 16 | muted: "#a6adc8", 17 | help: "#ebd391", 18 | info: "#89d88b", 19 | error: "#f37799", 20 | warn: "#ebd391", 21 | tasks: []string{ 22 | "#6bd7ca", 23 | "#89b4fa", 24 | "#94e2d5", 25 | "#cba6f7", 26 | "#eba0ac", 27 | "#f38ba8", 28 | "#f9e2af", 29 | "#fab387", 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /cmd/utils_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestExpandTilde(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | path string 13 | homeDir string 14 | expected string 15 | }{ 16 | { 17 | name: "a simple case", 18 | path: "~/some/path", 19 | homeDir: "/Users/trinity", 20 | expected: "/Users/trinity/some/path", 21 | }, 22 | { 23 | name: "path with no ~", 24 | path: "some/path", 25 | homeDir: "/Users/trinity", 26 | expected: "some/path", 27 | }, 28 | } 29 | 30 | for _, tt := range testCases { 31 | t.Run(tt.name, func(t *testing.T) { 32 | got := expandTilde(tt.path, tt.homeDir) 33 | 34 | assert.Equal(t, tt.expected, got) 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/scan.yml: -------------------------------------------------------------------------------- 1 | name: scan 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 6 10 * *' 7 | 8 | jobs: 9 | virus-total: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - name: Set up Go 14 | uses: actions/setup-go@v6 15 | with: 16 | go-version-file: 'go.mod' 17 | - name: Build Binaries 18 | uses: goreleaser/goreleaser-action@v6 19 | with: 20 | version: 'v2.9.0' 21 | args: build --snapshot 22 | - name: List binaries 23 | run: | 24 | ls -lh ./dist/hours_*/hours 25 | - uses: dhth/composite-actions/.github/actions/scan-files@main 26 | with: 27 | files: './dist/hours_*/hours' 28 | vt-api-key: ${{ secrets.VT_API_KEY }} 29 | -------------------------------------------------------------------------------- /tests/cli/themes/__snapshots__/TestShowConfig_help_flag_works_1.snap: -------------------------------------------------------------------------------- 1 | success: true 2 | exit_code: 0 3 | ----- stdout ----- 4 | Show JSON configuration for a theme 5 | 6 | Usage: 7 | hours themes show-config [flags] 8 | 9 | Examples: 10 | hours themes show-config # shows config for the current theme 11 | hours themes show-config -t 'default' # shows config for the default theme 12 | hours themes show-config -t 'monokai-classic' # shows config for the built-in theme "monokai-classic" 13 | hours themes show-config -t 'custom:yourtheme' # shows config for a user-defined theme, with missing elements replaced by those from the default theme 14 | 15 | Flags: 16 | -h, --help help for show-config 17 | -t, --theme string UI theme to show (run "hours themes list" for allowed values) (default "default") 18 | 19 | ----- stderr ----- 20 | 21 | -------------------------------------------------------------------------------- /internal/ui/styles_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/dhth/hours/internal/ui/theme" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetDynamicStyle(t *testing.T) { 11 | // GIVEN 12 | defaultTheme := theme.Default() 13 | style := NewStyle(defaultTheme) 14 | input := "abcdefghi" 15 | 16 | // WHEN 17 | gota := style.getDynamicStyle(input) 18 | gotb := style.getDynamicStyle(input) 19 | 20 | // THEN 21 | // assert same style returned for the same string 22 | assert.Equal(t, gota.GetForeground(), gotb.GetForeground()) 23 | } 24 | 25 | func TestGetDynamicStyleHandlesEmptyTaskList(t *testing.T) { 26 | // GIVEN 27 | defaultTheme := theme.Default() 28 | defaultTheme.Tasks = []string{} 29 | style := NewStyle(defaultTheme) 30 | input := "abcdefghi" 31 | 32 | // WHEN 33 | got := style.getDynamicStyle(input) 34 | 35 | // THEN 36 | assert.NotNil(t, got) 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | id-token: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Go 19 | uses: actions/setup-go@v6 20 | with: 21 | go-version-file: 'go.mod' 22 | - name: Build 23 | run: go build -v ./... 24 | - name: Test 25 | run: go test -v ./... 26 | - name: Install Cosign 27 | uses: sigstore/cosign-installer@v3 28 | with: 29 | cosign-release: 'v2.5.3' 30 | - name: Release Binaries 31 | uses: goreleaser/goreleaser-action@v6 32 | with: 33 | version: 'v2.9.0' 34 | args: release --clean 35 | env: 36 | GITHUB_TOKEN: ${{secrets.GH_PAT}} 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve hours 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Setup** 14 | Please complete the following information along with version numbers, if 15 | applicable. 16 | - OS [e.g. Ubuntu, macOS] 17 | - Shell [e.g. zsh, fish] 18 | - Terminal Emulator [e.g. kitty, iterm] 19 | - Terminal Multiplexer [e.g. tmux] 20 | - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.] 21 | 22 | **To Reproduce** 23 | Steps to reproduce the behavior: 24 | 1. ... 25 | 2. ... 26 | 3. Error occurs 27 | 28 | **Expected behavior** 29 | A clear and concise description of what you expected to happen. 30 | 31 | **Screenshots** 32 | Add screenshots to help explain your problem. 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestEmptyInactiveTaskListView_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Inactive Tasks 3 | 4 | No tasks 5 | 6 | No tasks. 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/theme/testdata/invalid-with-entire-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "activeTask": "#", 3 | "activeTaskBeginTime": "#", 4 | "activeTasks": "#", 5 | "formContext": "#", 6 | "formFieldName": "#", 7 | "formHelp": "#", 8 | "helpMsg": "#", 9 | "helpPrimary": "#", 10 | "helpSecondary": "#", 11 | "inactiveTasks": "#", 12 | "initialHelpMsg": "#", 13 | "listItemDesc": "#", 14 | "listItemTitle": "#", 15 | "recordsBorder": "#", 16 | "recordsDateRange": "#", 17 | "recordsFooter": "#", 18 | "recordsHeader": "#", 19 | "recordsHelp": "#", 20 | "taskLogDetails": "#", 21 | "taskEntry": "#", 22 | "taskLogEntry": "#", 23 | "taskLogList": "#", 24 | "taskLogFormInfo": "#", 25 | "taskLogFormWarn": "#", 26 | "taskLogFormError": "#", 27 | "tasks": [ 28 | "#", 29 | "#", 30 | "#", 31 | "#", 32 | "#", 33 | "#", 34 | "#", 35 | "#", 36 | "#", 37 | "#", 38 | "#", 39 | "#", 40 | "#", 41 | "#" 42 | ], 43 | "titleForeground": "#", 44 | "toolName": "#", 45 | "tracking": "#" 46 | } 47 | -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestTaskLogViewEmpty_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Logs (last 50) 3 | 4 | No entries 5 | 6 | No entries. 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | release: 4 | draft: true 5 | 6 | before: 7 | hooks: 8 | - go mod tidy 9 | - go generate ./... 10 | 11 | builds: 12 | - env: 13 | - CGO_ENABLED=0 14 | goos: 15 | - linux 16 | - darwin 17 | goarch: 18 | - amd64 19 | - arm64 20 | 21 | signs: 22 | - cmd: cosign 23 | signature: "${artifact}.sig" 24 | certificate: "${artifact}.pem" 25 | args: 26 | - "sign-blob" 27 | - "--oidc-issuer=https://token.actions.githubusercontent.com" 28 | - "--output-certificate=${certificate}" 29 | - "--output-signature=${signature}" 30 | - "${artifact}" 31 | - "--yes" 32 | artifacts: checksum 33 | 34 | brews: 35 | - name: hours 36 | repository: 37 | owner: dhth 38 | name: homebrew-tap 39 | directory: Formula 40 | license: MIT 41 | homepage: "https://github.com/dhth/hours" 42 | description: "A no-frills time tracking toolkit for the command line" 43 | 44 | changelog: 45 | sort: asc 46 | filters: 47 | exclude: 48 | - "^docs:" 49 | - "^test:" 50 | - "^ci:" 51 | -------------------------------------------------------------------------------- /internal/ui/theme/__snapshots__/TestLoadCustomLoadsFullThemeCorrectly_1.snap.yaml: -------------------------------------------------------------------------------- 1 | activeTask: "#8ec07c" 2 | activeTaskBeginTime: "#d3869b" 3 | activeTasks: "0" 4 | formContext: "#fabd2f" 5 | formFieldName: "#8ec07c" 6 | formHelp: "#928374" 7 | helpMsg: "255" 8 | helpPrimary: "#83a598" 9 | helpSecondary: "#bdae93" 10 | inactiveTasks: "#928374" 11 | initialHelpMsg: "#a58390" 12 | listItemDesc: "#777777" 13 | listItemTitle: "#dddddd" 14 | recordsBorder: "#665c54" 15 | recordsDateRange: "#fabd2f" 16 | recordsFooter: "#ef8f62" 17 | recordsHeader: "#d85d5d" 18 | recordsHelp: "#928374" 19 | taskEntry: "#8ec07c" 20 | taskLogDetails: "#d3869b" 21 | taskLogEntry: "#fabd2f" 22 | taskLogFormError: "#fb4934" 23 | taskLogFormInfo: "#d3869b" 24 | taskLogFormWarn: "#fe8019" 25 | taskLogList: "#b8bb26" 26 | tasks: 27 | - "#d3869b" 28 | - "#b5e48c" 29 | - "#90e0ef" 30 | - "#ca7df9" 31 | - "#ada7ff" 32 | - "#bbd0ff" 33 | - "#48cae4" 34 | - "#8187dc" 35 | - "#ffb4a2" 36 | - "#b8bb26" 37 | - "#ffc6ff" 38 | - "#4895ef" 39 | - "#83a598" 40 | - "#fabd2f" 41 | titleForeground: "#282828" 42 | toolName: "#fe8019" 43 | tracking: "#fabd2f" 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dhruv Thakur 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/types/types_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestHumanizeDuration(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | input int 13 | expected string 14 | }{ 15 | { 16 | name: "0 seconds", 17 | input: 0, 18 | expected: "0s", 19 | }, 20 | { 21 | name: "30 seconds", 22 | input: 30, 23 | expected: "30s", 24 | }, 25 | { 26 | name: "60 seconds", 27 | input: 60, 28 | expected: "1m", 29 | }, 30 | { 31 | name: "1805 seconds", 32 | input: 1805, 33 | expected: "30m", 34 | }, 35 | { 36 | name: "3605 seconds", 37 | input: 3605, 38 | expected: "1h", 39 | }, 40 | { 41 | name: "4200 seconds", 42 | input: 4200, 43 | expected: "1h 10m", 44 | }, 45 | { 46 | name: "87000 seconds", 47 | input: 87000, 48 | expected: "24h 10m", 49 | }, 50 | } 51 | 52 | for _, tt := range testCases { 53 | t.Run(tt.name, func(t *testing.T) { 54 | got := HumanizeDuration(tt.input) 55 | assert.Equal(t, tt.expected, got) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/ui/theme/__snapshots__/TestLoadCustomPartialThemeCorrectly_1.snap.yaml: -------------------------------------------------------------------------------- 1 | activeTask: "#8ec07c" 2 | activeTaskBeginTime: "#d3869b" 3 | activeTasks: "#fe8019" 4 | formContext: "#fabd2f" 5 | formFieldName: "#8ec07c" 6 | formHelp: "#928374" 7 | helpMsg: "#83a598" 8 | helpPrimary: "#fabd2f" 9 | helpSecondary: "#a89984" 10 | inactiveTasks: "#d3869b" 11 | initialHelpMsg: "#fabd2f" 12 | listItemDesc: "#a89984" 13 | listItemTitle: "#ebdbb2" 14 | recordsBorder: "#928374" 15 | recordsDateRange: "#fabd2f" 16 | recordsFooter: "#83a598" 17 | recordsHeader: "#fe8019" 18 | recordsHelp: "#a89984" 19 | taskEntry: "#fe8019" 20 | taskLogDetails: "#b8bb26" 21 | taskLogEntry: "#83a598" 22 | taskLogFormError: "#fb4934" 23 | taskLogFormInfo: "#8ec07c" 24 | taskLogFormWarn: "#d79921" 25 | taskLogList: "#b8bb26" 26 | tasks: 27 | - "#fe8019" 28 | - "#83a598" 29 | - "#b8bb26" 30 | - "#d3869b" 31 | - "#fabd2f" 32 | - "#8ec07c" 33 | - "#d79921" 34 | - "#fb4934" 35 | - "#7ec8e3" 36 | - "#98d4bb" 37 | - "#a3d9a5" 38 | - "#c9b1ff" 39 | - "#d5c4a1" 40 | - "#e09f9f" 41 | - "#f4a261" 42 | - "#f5d67b" 43 | titleForeground: "#282828" 44 | toolName: "#fe8019" 45 | tracking: "#83a598" 46 | -------------------------------------------------------------------------------- /internal/ui/active.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "time" 9 | 10 | pers "github.com/dhth/hours/internal/persistence" 11 | "github.com/dhth/hours/internal/types" 12 | ) 13 | 14 | const ( 15 | ActiveTaskPlaceholder = "{{task}}" 16 | ActiveTaskTimePlaceholder = "{{time}}" 17 | activeSecsThreshold = 60 18 | activeSecsThresholdStr = "<1m" 19 | ) 20 | 21 | func ShowActiveTask(db *sql.DB, writer io.Writer, template string) error { 22 | activeTaskDetails, err := pers.FetchActiveTaskDetails(db) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | if activeTaskDetails.TaskID == -1 { 28 | return nil 29 | } 30 | 31 | timeSpent := time.Since(activeTaskDetails.CurrentLogBeginTS).Seconds() 32 | var timeSpentStr string 33 | if timeSpent <= activeSecsThreshold { 34 | timeSpentStr = activeSecsThresholdStr 35 | } else { 36 | timeSpentStr = types.HumanizeDuration(int(timeSpent)) 37 | } 38 | 39 | activeStr := strings.Replace(template, ActiveTaskPlaceholder, activeTaskDetails.TaskSummary, 1) 40 | activeStr = strings.Replace(activeStr, ActiveTaskTimePlaceholder, timeSpentStr, 1) 41 | fmt.Fprint(writer, activeStr) 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "strings" 4 | 5 | func RightPadTrim(s string, length int, dots bool) string { 6 | if len(s) > length { 7 | if dots && length > 3 { 8 | return s[:length-3] + "..." 9 | } 10 | return s[:length] 11 | } 12 | return s + strings.Repeat(" ", length-len(s)) 13 | } 14 | 15 | func Trim(s string, length int) string { 16 | if len(s) > length { 17 | if length > 3 { 18 | return s[:length-3] + "..." 19 | } 20 | return s[:length] 21 | } 22 | return s 23 | } 24 | 25 | func TrimWithMoreLinesIndicator(s string, length int) string { 26 | lines := strings.SplitN(s, "\n", 2) 27 | 28 | if len(lines) > 1 { 29 | if length <= 5 { 30 | return Trim(lines[0], length) 31 | } 32 | return Trim(lines[0], length-2) + " ~" 33 | } 34 | 35 | return Trim(lines[0], length) 36 | } 37 | 38 | func RightPadTrimWithMoreLinesIndicator(s string, length int) string { 39 | lines := strings.SplitN(s, "\n", 2) 40 | 41 | if len(lines) > 1 { 42 | if length <= 5 { 43 | return RightPadTrim(lines[0], length, true) 44 | } 45 | 46 | return RightPadTrim(Trim(lines[0], length-2)+" ~", length, false) 47 | } 48 | 49 | return RightPadTrim(lines[0], length, true) 50 | } 51 | -------------------------------------------------------------------------------- /internal/ui/theme/testdata/valid-with-entire-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "activeTask": "#8ec07c", 3 | "activeTaskBeginTime": "#d3869b", 4 | "activeTasks": "0", 5 | "formContext": "#fabd2f", 6 | "formFieldName": "#8ec07c", 7 | "formHelp": "#928374", 8 | "helpMsg": "255", 9 | "helpPrimary": "#83a598", 10 | "helpSecondary": "#bdae93", 11 | "inactiveTasks": "#928374", 12 | "initialHelpMsg": "#a58390", 13 | "listItemDesc": "#777777", 14 | "listItemTitle": "#dddddd", 15 | "recordsBorder": "#665c54", 16 | "recordsDateRange": "#fabd2f", 17 | "recordsFooter": "#ef8f62", 18 | "recordsHeader": "#d85d5d", 19 | "recordsHelp": "#928374", 20 | "taskLogDetails": "#d3869b", 21 | "taskEntry": "#8ec07c", 22 | "taskLogEntry": "#fabd2f", 23 | "taskLogList": "#b8bb26", 24 | "taskLogFormInfo": "#d3869b", 25 | "taskLogFormWarn": "#fe8019", 26 | "taskLogFormError": "#fb4934", 27 | "tasks": [ 28 | "#d3869b", 29 | "#b5e48c", 30 | "#90e0ef", 31 | "#ca7df9", 32 | "#ada7ff", 33 | "#bbd0ff", 34 | "#48cae4", 35 | "#8187dc", 36 | "#ffb4a2", 37 | "#b8bb26", 38 | "#ffc6ff", 39 | "#4895ef", 40 | "#83a598", 41 | "#fabd2f" 42 | ], 43 | "titleForeground": "#282828", 44 | "toolName": "#fe8019", 45 | "tracking": "#fabd2f" 46 | } 47 | -------------------------------------------------------------------------------- /internal/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/dhth/hours/internal/types" 12 | ) 13 | 14 | var ( 15 | errFailedToConfigureDebugging = errors.New("failed to configure debugging") 16 | errCouldnCreateFramesDir = errors.New("couldn't create frames directory") 17 | ) 18 | 19 | func RenderUI(db *sql.DB, style Style, timeProvider types.TimeProvider) error { 20 | if len(os.Getenv("DEBUG")) > 0 { 21 | f, err := tea.LogToFile("debug.log", "debug") 22 | if err != nil { 23 | return fmt.Errorf("%w: %s", errFailedToConfigureDebugging, err.Error()) 24 | } 25 | defer f.Close() 26 | } 27 | 28 | debug := os.Getenv("HOURS_DEBUG") == "1" 29 | logFrames := os.Getenv("HOURS_LOG_FRAMES") == "1" 30 | logFramesCfg := logFramesConfig{ 31 | log: logFrames, 32 | } 33 | if logFrames { 34 | framesDir := filepath.Join(".frames", fmt.Sprintf("%d", timeProvider.Now().Unix())) 35 | err := os.MkdirAll(framesDir, 0o755) 36 | if err != nil { 37 | return fmt.Errorf("%w: %s", errCouldnCreateFramesDir, err.Error()) 38 | } 39 | logFramesCfg.framesDir = framesDir 40 | } 41 | 42 | p := tea.NewProgram( 43 | InitialModel( 44 | db, 45 | style, 46 | timeProvider, 47 | debug, 48 | logFramesCfg, 49 | ), 50 | tea.WithAltScreen(), 51 | ) 52 | _, err := p.Run() 53 | 54 | return err 55 | } 56 | -------------------------------------------------------------------------------- /tests/cli/themes/__snapshots__/TestShowConfig_works_for_built-in_theme_1.snap: -------------------------------------------------------------------------------- 1 | success: true 2 | exit_code: 0 3 | ----- stdout ----- 4 | { 5 | "activeTask": "#66d9ef", 6 | "activeTaskBeginTime": "#a6e22e", 7 | "activeTasks": "#66d9ef", 8 | "formContext": "#e6db74", 9 | "formFieldName": "#fdfff1", 10 | "formHelp": "#c0c1b5", 11 | "helpMsg": "#e6db74", 12 | "helpPrimary": "#e6db74", 13 | "helpSecondary": "#c0c1b5", 14 | "inactiveTasks": "#fd971f", 15 | "initialHelpMsg": "#e6db74", 16 | "listItemDesc": "#c0c1b5", 17 | "listItemTitle": "#fdfff1", 18 | "recordsBorder": "#57584f", 19 | "recordsDateRange": "#e6db74", 20 | "recordsFooter": "#ae81ff", 21 | "recordsHeader": "#66d9ef", 22 | "recordsHelp": "#c0c1b5", 23 | "taskEntry": "#66d9ef", 24 | "taskLogDetails": "#a6e22e", 25 | "taskLogEntry": "#ae81ff", 26 | "taskLogFormError": "#f92672", 27 | "taskLogFormInfo": "#a6e22e", 28 | "taskLogFormWarn": "#fd971f", 29 | "taskLogList": "#a6e22e", 30 | "tasks": [ 31 | "#66d9ef", 32 | "#ae81ff", 33 | "#a6e22e", 34 | "#fd971f", 35 | "#e6db74", 36 | "#a6e22e", 37 | "#fd971f", 38 | "#f92672", 39 | "#78dce8", 40 | "#a9dc76", 41 | "#ab9df2", 42 | "#c4e88a", 43 | "#d4bfff", 44 | "#ff6a9e", 45 | "#ffb86c", 46 | "#ffd866" 47 | ], 48 | "titleForeground": "#272822", 49 | "toolName": "#66d9ef", 50 | "tracking": "#ae81ff" 51 | } 52 | 53 | ----- stderr ----- 54 | 55 | -------------------------------------------------------------------------------- /cmd/errors.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/dhth/hours/internal/ui/theme" 10 | ) 11 | 12 | func handleError(err error) { 13 | if errors.Is(err, errCouldntGenerateData) { 14 | fmt.Fprintf(os.Stderr, "\n%s\n", msgReportIssue) 15 | return 16 | } 17 | 18 | if errors.Is(err, theme.ErrBuiltInThemeDoesntExist) { 19 | fmt.Fprintf(os.Stderr, ` 20 | If you intended to use a custom theme, prefix it with "custom:". Run "hours themes list" to list all themes. 21 | `) 22 | return 23 | } 24 | 25 | if errors.Is(err, theme.ErrCustomThemeDoesntExist) { 26 | fmt.Fprintf(os.Stderr, ` 27 | Run "hours themes list" to list custom themes. 28 | `) 29 | return 30 | } 31 | 32 | if errors.Is(err, theme.ErrThemeFileHasInvalidSchema) { 33 | defaultTheme := theme.Default() 34 | defaultThemeBytes, err := json.MarshalIndent(defaultTheme, "", " ") 35 | if err != nil { 36 | return 37 | } 38 | 39 | fmt.Fprintf(os.Stderr, ` 40 | A valid theme file looks like this: 41 | 42 | %s 43 | `, defaultThemeBytes) 44 | return 45 | } 46 | 47 | if errors.Is(err, theme.ErrThemeColorsAreInvalid) { 48 | fmt.Fprintf(os.Stderr, ` 49 | Color codes can only be provided in ANSI 16, ANSI 256, or HEX formats. 50 | 51 | For example: 52 | 53 | "activeTask": "9" # red in ANSI 16 54 | "activeTask": "201" # hot pink in ANSI 256 55 | "activeTask": "#0000FF" # blue in HEX (true color) 56 | 57 | Fun fact: There are 16,777,216 true color choices. Go nuts. 58 | `) 59 | return 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/types/duration.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | var ( 10 | errBeginTimeIsEmpty = errors.New("begin time is empty") 11 | errEndTimeIsEmpty = errors.New("end time is empty") 12 | errBeginTimeIsInvalid = errors.New("begin time is invalid") 13 | errEndTimeIsInvalid = errors.New("end time is invalid") 14 | errEndTimeBeforeBeginTime = errors.New("end time is before begin time") 15 | ErrDurationNotLongEnough = errors.New("end time needs to be at least a minute after begin time") 16 | ) 17 | 18 | func ParseTaskLogTimes(beginStr, endStr string) (time.Time, time.Time, error) { 19 | var zero time.Time 20 | if strings.TrimSpace(beginStr) == "" { 21 | return zero, zero, errBeginTimeIsEmpty 22 | } 23 | 24 | if strings.TrimSpace(endStr) == "" { 25 | return zero, zero, errEndTimeIsEmpty 26 | } 27 | 28 | beginTS, err := time.ParseInLocation(timeFormat, beginStr, time.Local) 29 | if err != nil { 30 | return zero, zero, errBeginTimeIsInvalid 31 | } 32 | 33 | endTS, err := time.ParseInLocation(timeFormat, endStr, time.Local) 34 | if err != nil { 35 | return zero, zero, errEndTimeIsInvalid 36 | } 37 | 38 | durationErr := IsTaskLogDurationValid(beginTS, endTS) 39 | if durationErr != nil { 40 | return zero, zero, durationErr 41 | } 42 | 43 | return beginTS, endTS, nil 44 | } 45 | 46 | func IsTaskLogDurationValid(begin, end time.Time) error { 47 | if end.Before(begin) { 48 | return errEndTimeBeforeBeginTime 49 | } 50 | 51 | if end.Sub(begin) < time.Minute { 52 | return ErrDurationNotLongEnough 53 | } 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /internal/persistence/migrations_test.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | _ "modernc.org/sqlite" // sqlite driver 9 | ) 10 | 11 | func TestMigrationsAreSetupCorrectly(t *testing.T) { 12 | // GIVEN 13 | // WHEN 14 | migrations := getMigrations() 15 | 16 | // THEN 17 | for i := 2; i <= latestDBVersion; i++ { 18 | m, ok := migrations[i] 19 | if !ok { 20 | assert.True(t, ok, "couldn't get migration %d", i) 21 | } 22 | if m == "" { 23 | assert.NotEmpty(t, ok, "migration %d is empty", i) 24 | } 25 | } 26 | } 27 | 28 | func TestMigrationsWork(t *testing.T) { 29 | // GIVEN 30 | var testDB *sql.DB 31 | var err error 32 | testDB, err = sql.Open("sqlite", ":memory:") 33 | if err != nil { 34 | t.Fatalf("Couldn't open database: %s", err.Error()) 35 | } 36 | 37 | err = InitDB(testDB) 38 | if err != nil { 39 | t.Fatalf("Couldn't initialize database: %s", err.Error()) 40 | } 41 | 42 | // WHEN 43 | err = UpgradeDB(testDB, 1) 44 | 45 | // THEN 46 | assert.NoError(t, err) 47 | } 48 | 49 | func TestRunMigrationFailsWhenGivenBadMigration(t *testing.T) { 50 | // GIVEN 51 | var testDB *sql.DB 52 | var err error 53 | testDB, err = sql.Open("sqlite", ":memory:") 54 | if err != nil { 55 | t.Fatalf("Couldn't open database: %s", err.Error()) 56 | } 57 | 58 | err = InitDB(testDB) 59 | if err != nil { 60 | t.Fatalf("Couldn't initialize database: %s", err.Error()) 61 | } 62 | 63 | // WHEN 64 | query := "BAD SQL CODE;" 65 | migrateErr := runMigration(testDB, query, 1) 66 | 67 | // THEN 68 | assert.Error(t, migrateErr) 69 | } 70 | -------------------------------------------------------------------------------- /internal/persistence/init.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | func InitDB(db *sql.DB) error { 9 | // these init queries cannot be changed 10 | // once hours is released; only further migrations 11 | // can be added, which are run whenever hours 12 | // sees a difference between the values in db_versions 13 | // and latestDBVersion 14 | _, err := db.Exec(` 15 | CREATE TABLE IF NOT EXISTS db_versions ( 16 | id INTEGER PRIMARY KEY AUTOINCREMENT, 17 | version INTEGER NOT NULL, 18 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 19 | ); 20 | 21 | CREATE TABLE IF NOT EXISTS task ( 22 | id INTEGER PRIMARY KEY AUTOINCREMENT, 23 | summary TEXT NOT NULL, 24 | secs_spent INTEGER NOT NULL DEFAULT 0, 25 | active BOOLEAN NOT NULL DEFAULT true, 26 | created_at TIMESTAMP NOT NULL, 27 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 28 | ); 29 | 30 | CREATE TABLE IF NOT EXISTS task_log ( 31 | id INTEGER PRIMARY KEY AUTOINCREMENT, 32 | task_id INTEGER, 33 | begin_ts TIMESTAMP NOT NULL, 34 | end_ts TIMESTAMP, 35 | secs_spent INTEGER NOT NULL DEFAULT 0, 36 | comment TEXT, 37 | active BOOLEAN NOT NULL, 38 | FOREIGN KEY(task_id) REFERENCES task(id) 39 | ); 40 | 41 | CREATE TRIGGER IF NOT EXISTS prevent_duplicate_active_insert 42 | BEFORE INSERT ON task_log 43 | BEGIN 44 | SELECT CASE 45 | WHEN EXISTS (SELECT 1 FROM task_log WHERE active = 1) 46 | THEN RAISE(ABORT, 'Only one row with active=1 is allowed') 47 | END; 48 | END; 49 | 50 | INSERT INTO db_versions (version, created_at) 51 | VALUES (1, ?); 52 | `, time.Now().UTC()) 53 | 54 | return err 55 | } 56 | -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestTaskListViewEmpty_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Tasks 3 | 4 | No tasks 5 | 6 | No tasks. 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press a to add a task Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestTaskListViewWithErrorMessage_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Tasks 3 | 4 | No tasks 5 | 6 | No tasks. 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | Error: Something went wrong 32 | hours Press a to add a task Press ? for help -------------------------------------------------------------------------------- /internal/ui/msgs.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/dhth/hours/internal/types" 7 | ) 8 | 9 | type hideHelpMsg struct{} 10 | 11 | type trackingToggledMsg struct { 12 | taskID int 13 | finished bool 14 | secsSpent int 15 | err error 16 | } 17 | 18 | type activeTLSwitchedMsg struct { 19 | lastActiveTaskID int 20 | currentlyActiveTaskID int 21 | currentlyActiveTLID int 22 | ts time.Time 23 | err error 24 | } 25 | 26 | type taskRepUpdatedMsg struct { 27 | tsk *types.Task 28 | err error 29 | } 30 | 31 | type manualTLInsertedMsg struct { 32 | taskID int 33 | err error 34 | } 35 | 36 | type savedTLEditedMsg struct { 37 | tlID int 38 | taskID int 39 | err error 40 | } 41 | 42 | type activeTLUpdatedMsg struct { 43 | beginTS time.Time 44 | comment *string 45 | err error 46 | } 47 | 48 | type activeTaskLogDeletedMsg struct { 49 | err error 50 | } 51 | 52 | type activeTaskFetchedMsg struct { 53 | activeTask types.ActiveTaskDetails 54 | noneActive bool 55 | err error 56 | } 57 | 58 | type tLsFetchedMsg struct { 59 | entries []types.TaskLogEntry 60 | tlIDToFocusOn *int 61 | err error 62 | } 63 | 64 | type taskCreatedMsg struct { 65 | err error 66 | } 67 | 68 | type taskUpdatedMsg struct { 69 | tsk *types.Task 70 | summary string 71 | err error 72 | } 73 | 74 | type taskActiveStatusUpdatedMsg struct { 75 | tsk *types.Task 76 | active bool 77 | err error 78 | } 79 | 80 | type tLDeletedMsg struct { 81 | entry *types.TaskLogEntry 82 | err error 83 | } 84 | 85 | type tasksFetchedMsg struct { 86 | tasks []types.Task 87 | active bool 88 | err error 89 | } 90 | 91 | type recordsDataFetchedMsg struct { 92 | dateRange types.DateRange 93 | report string 94 | err error 95 | } 96 | -------------------------------------------------------------------------------- /cmd/themes.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | _ "embed" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | 12 | "github.com/dhth/hours/internal/ui/theme" 13 | ) 14 | 15 | const themeNameRegexPattern = `^[a-zA-Z0-9-]{1,20}$` 16 | 17 | var themeNameRegExp = regexp.MustCompile(themeNameRegexPattern) 18 | 19 | //go:embed static/sample-theme.txt 20 | var sampleThemeConfig string 21 | 22 | var ( 23 | errThemeNameInvalid = fmt.Errorf("theme name is invalid; valid regex: %s", themeNameRegexPattern) 24 | errMarshallingDefaultTheme = errors.New("couldn't marshall default theme to bytes") 25 | errCouldntCreateThemesDir = errors.New("couldn't create themes directory") 26 | errCouldntCreateThemeFile = errors.New("couldn't create theme file") 27 | errCouldntWriteToThemeFile = errors.New("couldn't write to theme file") 28 | ) 29 | 30 | func addTheme(themeName string, themesDir string) (string, error) { 31 | var zero string 32 | if !themeNameRegExp.MatchString(themeName) { 33 | return zero, errThemeNameInvalid 34 | } 35 | 36 | defaultTheme := theme.Default() 37 | themeBytes, err := json.MarshalIndent(defaultTheme, "", " ") 38 | if err != nil { 39 | return zero, fmt.Errorf("%w: %s", errMarshallingDefaultTheme, err.Error()) 40 | } 41 | 42 | err = os.MkdirAll(themesDir, 0o755) 43 | if err != nil { 44 | return zero, fmt.Errorf("%w: %s", errCouldntCreateThemesDir, err.Error()) 45 | } 46 | 47 | themePath := filepath.Join(themesDir, fmt.Sprintf("%s.json", themeName)) 48 | 49 | file, err := os.Create(themePath) 50 | if err != nil { 51 | return zero, fmt.Errorf("%w: %s", errCouldntCreateThemeFile, err.Error()) 52 | } 53 | defer file.Close() 54 | 55 | _, err = file.Write(themeBytes) 56 | if err != nil { 57 | return zero, fmt.Errorf("%w: %s", errCouldntWriteToThemeFile, err.Error()) 58 | } 59 | 60 | return themePath, nil 61 | } 62 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - errname 5 | - errorlint 6 | - goconst 7 | - intrange 8 | - nilerr 9 | - prealloc 10 | - predeclared 11 | - revive 12 | - rowserrcheck 13 | - sqlclosecheck 14 | - testifylint 15 | - thelper 16 | - unconvert 17 | - usestdlibvars 18 | - wastedassign 19 | settings: 20 | revive: 21 | rules: 22 | - name: blank-imports 23 | - name: context-as-argument 24 | arguments: 25 | - allowTypesBefore: '*testing.T' 26 | - name: context-keys-type 27 | - name: dot-imports 28 | - name: empty-block 29 | - name: error-naming 30 | - name: error-return 31 | - name: error-strings 32 | - name: errorf 33 | - name: exported 34 | - name: if-return 35 | - name: increment-decrement 36 | - name: indent-error-flow 37 | - name: package-comments 38 | - name: range 39 | - name: receiver-naming 40 | - name: redefines-builtin-id 41 | - name: superfluous-else 42 | - name: time-naming 43 | - name: unexported-return 44 | - name: unreachable-code 45 | - name: unused-parameter 46 | - name: var-declaration 47 | - name: unnecessary-stmt 48 | - name: deep-exit 49 | - name: confusing-naming 50 | - name: unused-receiver 51 | - name: unhandled-error 52 | arguments: 53 | - fmt.Print 54 | - fmt.Printf 55 | - fmt.Fprintf 56 | - fmt.Fprint 57 | exclusions: 58 | generated: lax 59 | presets: 60 | - comments 61 | - common-false-positives 62 | - legacy 63 | - std-error-handling 64 | paths: 65 | - third_party$ 66 | - builtin$ 67 | - examples$ 68 | formatters: 69 | enable: 70 | - gofumpt 71 | exclusions: 72 | generated: lax 73 | paths: 74 | - third_party$ 75 | - builtin$ 76 | - examples$ 77 | -------------------------------------------------------------------------------- /tests/cli/themes/show_config_test.go: -------------------------------------------------------------------------------- 1 | package themes 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/dhth/hours/tests/cli" 7 | "github.com/gkampitakis/go-snaps/snaps" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestShowConfig(t *testing.T) { 12 | fx, err := cli.NewFixture() 13 | require.NoErrorf(t, err, "error setting up fixture: %s", err) 14 | 15 | defer func() { 16 | err := fx.Cleanup() 17 | require.NoErrorf(t, err, "error cleaning up fixture: %s", err) 18 | }() 19 | 20 | commonArgs := []string{ 21 | "themes", 22 | "show-config", 23 | } 24 | 25 | //-------------// 26 | // SUCCESSES // 27 | //-------------// 28 | 29 | t.Run("help flag works", func(t *testing.T) { 30 | // GIVEN 31 | cmd := cli.NewCmd(commonArgs) 32 | cmd.AddArgs("--help") 33 | 34 | // WHEN 35 | result, err := fx.RunCmd(cmd) 36 | 37 | // THEN 38 | require.NoError(t, err) 39 | snaps.MatchStandaloneSnapshot(t, result) 40 | }) 41 | 42 | t.Run("works for built-in theme", func(t *testing.T) { 43 | // GIVEN 44 | cmd := cli.NewCmd(commonArgs) 45 | cmd.AddArgs("--theme", "monokai-classic") 46 | 47 | // WHEN 48 | result, err := fx.RunCmd(cmd) 49 | 50 | // THEN 51 | require.NoError(t, err) 52 | snaps.MatchStandaloneSnapshot(t, result) 53 | }) 54 | 55 | //------------// 56 | // FAILURES // 57 | //------------// 58 | 59 | t.Run("fails for incorrect builtin theme", func(t *testing.T) { 60 | // GIVEN 61 | cmd := cli.NewCmd(commonArgs) 62 | cmd.AddArgs("--theme", "unknown") 63 | 64 | // WHEN 65 | result, err := fx.RunCmd(cmd) 66 | 67 | // THEN 68 | require.NoError(t, err) 69 | snaps.MatchStandaloneSnapshot(t, result) 70 | }) 71 | 72 | t.Run("fails for incorrect builtin theme provided via env var", func(t *testing.T) { 73 | // GIVEN 74 | cmd := cli.NewCmd(commonArgs) 75 | cmd.SetEnv("HOURS_THEME", "unknown") 76 | 77 | // WHEN 78 | result, err := fx.RunCmd(cmd) 79 | 80 | // THEN 81 | require.NoError(t, err) 82 | snaps.MatchStandaloneSnapshot(t, result) 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /internal/ui/theme/builtin.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | type builtInThemePalette struct { 4 | primary string 5 | secondary string 6 | tertiary string 7 | quaternary string 8 | foreground string 9 | text string 10 | subtext string 11 | muted string 12 | help string 13 | info string 14 | error string 15 | warn string 16 | tasks []string 17 | } 18 | 19 | func getBuiltInTheme(palette builtInThemePalette) Theme { 20 | taskColors := []string{ 21 | palette.primary, 22 | palette.secondary, 23 | palette.tertiary, 24 | palette.quaternary, 25 | palette.help, 26 | palette.info, 27 | palette.warn, 28 | palette.error, 29 | } 30 | taskColors = append(taskColors, palette.tasks...) 31 | 32 | return Theme{ 33 | ActiveTask: palette.primary, 34 | ActiveTaskBeginTime: palette.tertiary, 35 | ActiveTasks: palette.primary, 36 | FormContext: palette.help, 37 | FormFieldName: palette.text, 38 | FormHelp: palette.subtext, 39 | HelpMsg: palette.help, 40 | HelpPrimary: palette.help, 41 | HelpSecondary: palette.subtext, 42 | InactiveTasks: palette.quaternary, 43 | InitialHelpMsg: palette.help, 44 | ListItemDesc: palette.subtext, 45 | ListItemTitle: palette.text, 46 | RecordsBorder: palette.muted, 47 | RecordsDateRange: palette.help, 48 | RecordsFooter: palette.secondary, 49 | RecordsHeader: palette.primary, 50 | RecordsHelp: palette.subtext, 51 | TaskEntry: palette.primary, 52 | TaskLogDetailsViewTitle: palette.tertiary, 53 | TaskLogEntry: palette.secondary, 54 | TaskLogFormError: palette.error, 55 | TaskLogFormInfo: palette.info, 56 | TaskLogFormWarn: palette.warn, 57 | TaskLogList: palette.tertiary, 58 | Tasks: taskColors, 59 | TitleForeground: palette.foreground, 60 | ToolName: palette.primary, 61 | Tracking: palette.secondary, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestTaskListViewDebugMode_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Tasks 3 | 4 | 3 tasks 5 | 6 | │ Implement feature A 7 | │ last updated: 3 hours ago no time spent 8 | 9 | Fix bug in module B 10 | last updated: 3 hours ago no time spent 11 | 12 | Write documentation 13 | last updated: 3 hours ago no time spent 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours [term: 96x32] [msg frames left: 0] [frames rendered: 0] -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestTaskListViewWithTasks_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Tasks 3 | 4 | 3 tasks 5 | 6 | │ Implement feature A 7 | │ last updated: 3 hours ago no time spent 8 | 9 | ⏲ Fix bug in module B 10 | last updated: 3 hours ago no time spent 11 | 12 | Write documentation 13 | last updated: 3 hours ago no time spent 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help tracking: Fix bug in module B (since 09:00) -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestTaskListViewWithInfoContext_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Tasks 3 | 4 | 1 task 5 | 6 | │ Implement feature A 7 | │ last updated: 3 hours ago no time spent 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | Task created successfully 32 | hours Press s to start tracking time Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestInactiveTaskListViewWithTasks_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Inactive Tasks 3 | 4 | 2 tasks 5 | 6 | │ Archived feature 7 | │ last updated: 3 hours ago no time spent 8 | 9 | Completed bug fix 10 | last updated: 3 hours ago no time spent 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestEditActiveTLView_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Log Entry 3 | 4 | Updating log entry. Enter the following details. 5 | 6 | Begin Time* (format: 2006/01/02 15:04) 7 | 8 | > 2025/08/17 09:00 (j/k/J/K/h/l moves time) 9 | 10 | Comment (15/3000) 11 | 12 | ┃ Updated comment 13 | ┃ 14 | ┃ 15 | ┃ 16 | ┃ 17 | ┃ 18 | ┃ 19 | ┃ 20 | ┃ 21 | ┃ 22 | 23 | Press / to submit 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestTerminalWidthResizingWorks_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Logs (last 50) 3 | 4 | 3 entries 5 | 6 | │ Test work on task 7 | │ Implement feature A 06:30 ... 08… 8 | 9 | Test work on task 10 | Fix bug in module B 06:30 ... 08… 11 | 12 | Test work on task 13 | Implement feature A 06:30 ... 08… 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestCreateTaskView_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Add task 3 | 4 | > a new task 5 | 6 | Press / to submit 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestUpdateTaskView_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Update task 3 | 4 | > a task to be updated 5 | 6 | Press / to submit 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestCreateTaskViewWithNoInput_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Add task 3 | 4 | > task summary goes here 5 | 6 | Press / to submit 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestEditSavedTLView_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Log Entry 3 | 4 | Updating log entry. Enter the following details. 5 | 6 | Use tab/shift-tab to move between sections; esc to go back. 7 | 8 | Begin Time* (format: 2006/01/02 15:04) 9 | 10 | > 2025/08/17 09:00 (j/k/J/K/h/l moves time) 11 | 12 | End Time* (format: 2006/01/02 15:04) 13 | 14 | > 2025/08/17 10:30 (j/k/J/K/h/l moves time) 15 | 16 | Comment (21/3000) 17 | 18 | ┃ Edited saved task log 19 | ┃ 20 | ┃ 21 | ┃ 22 | ┃ 23 | ┃ 24 | ┃ 25 | ┃ 26 | ┃ 27 | ┃ 28 | 29 | You're recording 1h 30m 30 | 31 | Press / to submit 32 | 33 | 34 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestFinishActiveTLView_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Log Entry 3 | 4 | Saving log entry. Enter the following details. 5 | 6 | Use tab/shift-tab to move between sections; esc to go back. 7 | 8 | Begin Time* (format: 2006/01/02 15:04) 9 | 10 | > 2025/08/17 09:00 (j/k/J/K/h/l moves time) 11 | 12 | End Time* (format: 2006/01/02 15:04) 13 | 14 | > 2025/08/17 10:30 (j/k/J/K/h/l moves time) 15 | 16 | Comment (31/3000) 17 | 18 | ┃ Test comment for finishing task 19 | ┃ 20 | ┃ 21 | ┃ 22 | ┃ 23 | ┃ 24 | ┃ 25 | ┃ 26 | ┃ 27 | ┃ 28 | 29 | You're recording 1h 30m 30 | 31 | Press / to submit 32 | 33 | 34 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestManualTasklogEntryView_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Log Entry 3 | 4 | Adding a manual log entry. Enter the following details. 5 | 6 | Use tab/shift-tab to move between sections; esc to go back. 7 | 8 | Begin Time* (format: 2006/01/02 15:04) 9 | 10 | > 2025/08/17 09:00 (j/k/J/K/h/l moves time) 11 | 12 | End Time* (format: 2006/01/02 15:04) 13 | 14 | > 2025/08/17 10:30 (j/k/J/K/h/l moves time) 15 | 16 | Comment (21/3000) 17 | 18 | ┃ Manual task log entry 19 | ┃ 20 | ┃ 21 | ┃ 22 | ┃ 23 | ┃ 24 | ┃ 25 | ┃ 26 | ┃ 27 | ┃ 28 | 29 | You're recording 1h 30m 30 | 31 | Press / to submit 32 | 33 | 34 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestFinishActiveTLViewWhereNoTimeTracked_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Log Entry 3 | 4 | Saving log entry. Enter the following details. 5 | 6 | Use tab/shift-tab to move between sections; esc to go back. 7 | 8 | Begin Time* (format: 2006/01/02 15:04) 9 | 10 | > 2025/08/17 10:30 (j/k/J/K/h/l moves time) 11 | 12 | End Time* (format: 2006/01/02 15:04) 13 | 14 | > 2025/08/17 10:30 (j/k/J/K/h/l moves time) 15 | 16 | Comment (optional) 17 | 18 | ┃ Task log comment goes here. 19 | ┃ 20 | ┃ This can be used to record details about your work on this task. 21 | ┃ 22 | ┃ 23 | ┃ 24 | ┃ 25 | ┃ 26 | ┃ 27 | ┃ 28 | 29 | Error: end time needs to be at least a minute after begin time 30 | 31 | 32 | 33 | 34 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestFinishActiveTLViewWithWarningContext_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Log Entry 3 | 4 | Saving log entry. Enter the following details. 5 | 6 | Use tab/shift-tab to move between sections; esc to go back. 7 | 8 | Begin Time* (format: 2006/01/02 15:04) 9 | 10 | > 2025/08/17 09:00 (j/k/J/K/h/l moves time) 11 | 12 | End Time* (format: 2006/01/02 15:04) 13 | 14 | > 2025/08/17 18:30 (j/k/J/K/h/l moves time) 15 | 16 | Comment (optional) 17 | 18 | ┃ Task log comment goes here. 19 | ┃ 20 | ┃ This can be used to record details about your work on this task. 21 | ┃ 22 | ┃ 23 | ┃ 24 | ┃ 25 | ┃ 26 | ┃ 27 | ┃ 28 | 29 | You're recording 9h 30m 30 | 31 | Press / to submit 32 | 33 | 34 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestFinishActiveTLViewWhereEndTimeBeforeBeginTime_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Log Entry 3 | 4 | Saving log entry. Enter the following details. 5 | 6 | Use tab/shift-tab to move between sections; esc to go back. 7 | 8 | Begin Time* (format: 2006/01/02 15:04) 9 | 10 | > 2025/08/17 10:30 (j/k/J/K/h/l moves time) 11 | 12 | End Time* (format: 2006/01/02 15:04) 13 | 14 | > 2025/08/17 09:00 (j/k/J/K/h/l moves time) 15 | 16 | Comment (optional) 17 | 18 | ┃ Task log comment goes here. 19 | ┃ 20 | ┃ This can be used to record details about your work on this task. 21 | ┃ 22 | ┃ 23 | ┃ 24 | ┃ 25 | ┃ 26 | ┃ 27 | ┃ 28 | 29 | Error: end time is before begin time 30 | 31 | 32 | 33 | 34 | hours Press ? for help -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRightPadTrim(t *testing.T) { 10 | inputStr := "hello" 11 | tests := []struct { 12 | input string 13 | length int 14 | dots bool 15 | expected string 16 | }{ 17 | {inputStr, 10, false, "hello "}, 18 | {inputStr, 10, true, "hello "}, 19 | {inputStr, 5, false, "hello"}, 20 | {inputStr, 5, true, "hello"}, 21 | {inputStr, 4, false, "hell"}, 22 | {inputStr, 4, true, "h..."}, 23 | {inputStr, 3, true, "hel"}, 24 | {inputStr, 3, false, "hel"}, 25 | {inputStr, 2, true, "he"}, 26 | {inputStr, 2, false, "he"}, 27 | {inputStr, 1, false, "h"}, 28 | {inputStr, 0, false, ""}, 29 | } 30 | 31 | for _, tc := range tests { 32 | got := RightPadTrim(tc.input, tc.length, tc.dots) 33 | assert.Equal(t, tc.expected, got, "length: %d, dots: %v", tc.length, tc.dots) 34 | } 35 | } 36 | 37 | func TestTrim(t *testing.T) { 38 | inputStr := "hello" 39 | tests := []struct { 40 | input string 41 | length int 42 | expected string 43 | }{ 44 | {inputStr, 5, "hello"}, 45 | {inputStr, 6, "hello"}, 46 | {inputStr, 4, "h..."}, 47 | {inputStr, 3, "hel"}, 48 | {inputStr, 2, "he"}, 49 | {inputStr, 1, "h"}, 50 | {inputStr, 0, ""}, 51 | } 52 | 53 | for _, tc := range tests { 54 | got := Trim(tc.input, tc.length) 55 | assert.Equal(t, tc.expected, got, "input: %s, length: %d", tc.input, tc.length) 56 | } 57 | } 58 | 59 | func TestTrimWithMoreLinesIndicator(t *testing.T) { 60 | tests := []struct { 61 | input string 62 | length int 63 | expected string 64 | }{ 65 | {"hello", 10, "hello"}, 66 | {"hello", 5, "hello"}, 67 | {"hello", 4, "h..."}, 68 | {"hello", 3, "hel"}, 69 | {"hello\nworld", 10, "hello ~"}, 70 | {"hello\nworld", 7, "hello ~"}, 71 | {"hello\nworld", 5, "hello"}, 72 | {"hello\nworld", 4, "h..."}, 73 | {"hello\nworld", 3, "hel"}, 74 | } 75 | 76 | for _, tc := range tests { 77 | got := TrimWithMoreLinesIndicator(tc.input, tc.length) 78 | assert.Equal(t, tc.expected, got, "input: %s, length: %d", tc.input, tc.length) 79 | } 80 | } 81 | 82 | func TestRightPadTrimWithMoreLinesIndicator(t *testing.T) { 83 | tests := []struct { 84 | input string 85 | length int 86 | expected string 87 | }{ 88 | {"hello", 10, "hello "}, 89 | {"hello", 5, "hello"}, 90 | {"hello", 4, "h..."}, 91 | {"hello", 3, "hel"}, 92 | {"hello\nworld", 10, "hello ~ "}, 93 | {"hello\nworld", 7, "hello ~"}, 94 | {"hello\nworld", 5, "hello"}, 95 | {"hello\nworld", 4, "h..."}, 96 | {"hello\nworld", 3, "hel"}, 97 | } 98 | 99 | for _, tc := range tests { 100 | got := RightPadTrimWithMoreLinesIndicator(tc.input, tc.length) 101 | assert.Equal(t, tc.expected, got, "input: %s, length: %d", tc.input, tc.length) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/cli/fixture.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | ) 11 | 12 | type Fixture struct { 13 | tempDir string 14 | binPath string 15 | } 16 | 17 | type HoursCmd struct { 18 | args []string 19 | useDB bool 20 | env map[string]string 21 | } 22 | 23 | func NewCmd(args []string) HoursCmd { 24 | return HoursCmd{ 25 | args: args, 26 | env: make(map[string]string), 27 | } 28 | } 29 | 30 | func (c *HoursCmd) AddArgs(args ...string) { 31 | c.args = append(c.args, args...) 32 | } 33 | 34 | func (c *HoursCmd) SetEnv(key, value string) { 35 | c.env[key] = value 36 | } 37 | 38 | func (c *HoursCmd) UseDB() { 39 | c.useDB = true 40 | } 41 | 42 | func NewFixture() (Fixture, error) { 43 | var zero Fixture 44 | tempDir, err := os.MkdirTemp("", "") 45 | if err != nil { 46 | return zero, fmt.Errorf("couldn't create temporary directory: %s", err.Error()) 47 | } 48 | 49 | binPath := filepath.Join(tempDir, "hours") 50 | buildArgs := []string{"build", "-o", binPath, "../../.."} 51 | 52 | c := exec.Command("go", buildArgs...) 53 | buildOutput, err := c.CombinedOutput() 54 | if err != nil { 55 | cleanupErr := os.RemoveAll(tempDir) 56 | if cleanupErr != nil { 57 | fmt.Fprintf(os.Stderr, "couldn't clean up temporary directory (%s): %s", tempDir, cleanupErr.Error()) 58 | } 59 | 60 | return zero, fmt.Errorf(`couldn't build binary: %s 61 | output: 62 | %s`, err.Error(), buildOutput) 63 | } 64 | 65 | return Fixture{ 66 | tempDir: tempDir, 67 | binPath: binPath, 68 | }, nil 69 | } 70 | 71 | func (f Fixture) Cleanup() error { 72 | err := os.RemoveAll(f.tempDir) 73 | if err != nil { 74 | return fmt.Errorf("couldn't clean up temporary directory (%s): %s", f.tempDir, err.Error()) 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func (f Fixture) RunCmd(cmd HoursCmd) (string, error) { 81 | argsToUse := cmd.args 82 | if cmd.useDB { 83 | dbPath := filepath.Join(f.tempDir, "hours.db") 84 | argsToUse = append(argsToUse, "--dbpath", dbPath) 85 | } 86 | cmdToRun := exec.Command(f.binPath, argsToUse...) 87 | 88 | cmdToRun.Env = os.Environ() 89 | for key, value := range cmd.env { 90 | cmdToRun.Env = append(cmdToRun.Env, fmt.Sprintf("%s=%s", key, value)) 91 | } 92 | 93 | var stdoutBuf, stderrBuf bytes.Buffer 94 | cmdToRun.Stdout = &stdoutBuf 95 | cmdToRun.Stderr = &stderrBuf 96 | 97 | err := cmdToRun.Run() 98 | exitCode := 0 99 | success := true 100 | 101 | if err != nil { 102 | var exitError *exec.ExitError 103 | if errors.As(err, &exitError) { 104 | success = false 105 | exitCode = exitError.ExitCode() 106 | } else { 107 | return "", fmt.Errorf("couldn't run command: %s", err.Error()) 108 | } 109 | } 110 | 111 | output := fmt.Sprintf(`success: %t 112 | exit_code: %d 113 | ----- stdout ----- 114 | %s 115 | ----- stderr ----- 116 | %s 117 | `, success, exitCode, stdoutBuf.String(), stderrBuf.String()) 118 | 119 | return output, nil 120 | } 121 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ### Added 11 | 12 | - Built-in themes 13 | 14 | ### Changed 15 | 16 | - Minor changes to default theme 17 | - "--theme" flag considers built-in themes by default; custom themes can be 18 | referenced using "custom:" prefix 19 | 20 | ## [v0.6.0] - Aug 18, 2025 21 | 22 | ### Added 23 | 24 | - Allow filtering tasks by status in analytics commands (log, report, stats) 25 | - Keymap to finish active task log without comment 26 | - Contextual cues in the "Task Log Entry" view 27 | 28 | ### Changed 29 | 30 | - Removed date range limit for stats and log commands 31 | - Missing end date in date range implies today (eg. 2025/08/12...) 32 | - "today" can be used in date range (eg. 2025/08/12...today) 33 | - Improved TUI navigation: esc/q now function in more panes, returning the user 34 | to previous panes in a predictable manner 35 | - User messages in the TUI remain visible for a while 36 | - Minimum terminal width needed brought down to 80 characters (from 96) 37 | 38 | ## [v0.5.0] - Feb 22, 2025 39 | 40 | ### Added 41 | 42 | - Support for custom themes and subcommands to manage them 43 | 44 | ### Changed 45 | 46 | - Go, dependency upgrades 47 | 48 | ## [v0.4.1] - Feb 03, 2025 49 | 50 | ### Changed 51 | 52 | - Minor wording changes in error messages 53 | 54 | ## [v0.4.0] - Jan 19, 2025 55 | 56 | ### Added 57 | 58 | - Time tracking can now be switched between tasks with a single keypress 59 | - The active task log can now be edited before it's finished 60 | - Task logs can now be edited after saving 61 | - Adds a view for viewing task log details 62 | 63 | ### Changed 64 | 65 | - Allow for longer task log comments 66 | - Task log comments can now be empty 67 | 68 | ## [v0.3.0] - Jun 29, 2024 69 | 70 | ### Added 71 | 72 | - Timestamps in the "Task Log Entry" view can be moved forwards/backwards using 73 | j/k/J/K 74 | - The TUI now shows the start time of an active recording 75 | - An active task log recording can now be cancelled 76 | 77 | ### Changed 78 | 79 | - Timestamps in "Task Log" view show up differently based on the end timestamp 80 | - "active" subcommand supports a time placeholder, eg. hours active -t 'working 81 | on {{task}} for {{time}}' 82 | 83 | ## [v0.2.0] - Jun 21, 2024 84 | 85 | ### Added 86 | 87 | - Adds the ability to view reports/logs/stats interactively (using the 88 | --interactive/-i flag) 89 | - Adds the "gen" subcommand to allow new users of "hours" to generate dummy data 90 | 91 | [unreleased]: https://github.com/dhth/hours/compare/v0.6.0...HEAD 92 | [v0.6.0]: https://github.com/dhth/hours/compare/v0.5.0...v0.6.0 93 | [v0.5.0]: https://github.com/dhth/hours/compare/v0.4.1...v0.5.0 94 | [v0.4.1]: https://github.com/dhth/hours/compare/v0.4.0...v0.4.1 95 | [v0.4.0]: https://github.com/dhth/hours/compare/v0.3.0...v0.4.0 96 | [v0.3.0]: https://github.com/dhth/hours/compare/v0.2.0...v0.3.0 97 | [v0.2.0]: https://github.com/dhth/hours/compare/v0.1.0...v0.2.0 98 | -------------------------------------------------------------------------------- /cmd/static/sample-theme.txt: -------------------------------------------------------------------------------- 1 | { 2 | "activeTask": "#8ec07c", # color for the active task in the footer 3 | "activeTaskBeginTime": "#d3869b", # color for the active task begin time in the footer 4 | "activeTasks": "#fe8019", # primary color for the active task list view 5 | "formContext": "#fabd2f", # color for the context message in all forms 6 | "formFieldName": "#8ec07c", # color for field names in all forms 7 | "formHelp": "#928374", # color for the help text in all forms 8 | "helpMsg": "#83a598", # color for help messages in the footer 9 | "helpPrimary": "#83a598", # primary color for the help view 10 | "helpSecondary": "#bdae93", # secondary color for the help view 11 | "inactiveTasks": "#928374", # primary color for the inactive task list view 12 | "initialHelpMsg": "#a58390", # color of the initial help message in the footer 13 | "listItemDesc": "#777777", # color to be used for the title of list items (when they're not selected) 14 | "listItemTitle": "#dddddd", # color to be used for the title of list items (when they're not selected) 15 | "recordsBorder": "#665c54", # color for the table border in the output of logs, reports, stats 16 | "recordsDateRange": "#fabd2f", # color for the data range picker in the output of logs, reports, stats 17 | "recordsFooter": "#ef8f62", # color for the footer row in the output of logs, reports, stats 18 | "recordsHeader": "#d85d5d", # color for the header row in the output of logs, reports, stats 19 | "recordsHelp": "#928374", # color for the help message in the output of logs, reports, stats 20 | "taskLogDetails": "#d3869b", # primary color for the task log details view 21 | "taskEntry": "#8ec07c", # primary color for the task entry view 22 | "taskLogEntry": "#fabd2f", # primary color for the task log entry view 23 | "taskLogList": "#b8bb26", # primary color for the task log list view 24 | "taskLogFormInfo": "#d3869b", # color to use for contextual information in the task log form 25 | "taskLogFormWarn": "#fe8019", # color to use for contextual warnings in the task log form 26 | "taskLogFormError": "#fb4934", # color to use for contextual errors in the task log form 27 | "tasks": [ # colors to be used for tasks in the output of logs, report, stats 28 | "#d3869b", 29 | "#b5e48c", 30 | "#90e0ef", 31 | "#ca7df9", 32 | "#ada7ff", 33 | "#bbd0ff", 34 | "#48cae4", 35 | "#8187dc", 36 | "#ffb4a2", 37 | "#b8bb26", 38 | "#ffc6ff", 39 | "#4895ef", 40 | "#83a598", 41 | "#fabd2f" 42 | ], 43 | "titleForeground": "#282828", # foreground color to use for the title of all views 44 | "toolName": "#fe8019", # color for the tool name in the footer 45 | "tracking": "#fabd2f" # color for the tracking message in the footer 46 | } 47 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dhth/hours 2 | 3 | go 1.25.5 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.21.0 7 | github.com/charmbracelet/bubbletea v1.3.10 8 | github.com/charmbracelet/lipgloss v1.1.0 9 | github.com/dustin/go-humanize v1.0.1 10 | github.com/gkampitakis/go-snaps v0.5.17 11 | github.com/olekukonko/tablewriter v1.1.2 12 | github.com/spf13/cobra v1.10.1 13 | github.com/stretchr/testify v1.11.1 14 | modernc.org/sqlite v1.40.1 15 | ) 16 | 17 | require ( 18 | github.com/atotto/clipboard v0.1.4 // indirect 19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 | github.com/charmbracelet/colorprofile v0.3.2 // indirect 21 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 22 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 23 | github.com/charmbracelet/x/term v0.2.1 // indirect 24 | github.com/clipperhouse/displaywidth v0.6.0 // indirect 25 | github.com/clipperhouse/stringish v0.1.1 // indirect 26 | github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 29 | github.com/fatih/color v1.18.0 // indirect 30 | github.com/gkampitakis/ciinfo v0.3.3 // indirect 31 | github.com/goccy/go-yaml v1.18.0 // indirect 32 | github.com/google/uuid v1.6.0 // indirect 33 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 34 | github.com/kr/pretty v0.3.1 // indirect 35 | github.com/kr/text v0.2.0 // indirect 36 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 37 | github.com/maruel/natural v1.1.1 // indirect 38 | github.com/mattn/go-colorable v0.1.14 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/mattn/go-localereader v0.0.1 // indirect 41 | github.com/mattn/go-runewidth v0.0.19 // indirect 42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 43 | github.com/muesli/cancelreader v0.2.2 // indirect 44 | github.com/muesli/termenv v0.16.0 // indirect 45 | github.com/ncruces/go-strftime v0.1.9 // indirect 46 | github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect 47 | github.com/olekukonko/errors v1.1.0 // indirect 48 | github.com/olekukonko/ll v0.1.3 // indirect 49 | github.com/pmezard/go-difflib v1.0.0 // indirect 50 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 51 | github.com/rivo/uniseg v0.4.7 // indirect 52 | github.com/rogpeppe/go-internal v1.14.1 // indirect 53 | github.com/sahilm/fuzzy v0.1.1 // indirect 54 | github.com/sergi/go-diff v1.4.0 // indirect 55 | github.com/spf13/pflag v1.0.9 // indirect 56 | github.com/tidwall/gjson v1.18.0 // indirect 57 | github.com/tidwall/match v1.1.1 // indirect 58 | github.com/tidwall/pretty v1.2.1 // indirect 59 | github.com/tidwall/sjson v1.2.5 // indirect 60 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 61 | golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect 62 | golang.org/x/sys v0.36.0 // indirect 63 | golang.org/x/text v0.29.0 // indirect 64 | gopkg.in/yaml.v3 v3.0.1 // indirect 65 | modernc.org/libc v1.66.10 // indirect 66 | modernc.org/mathutil v1.7.1 // indirect 67 | modernc.org/memory v1.11.0 // indirect 68 | ) 69 | -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestHelpView_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Help (scroll with j/k/↓/↑) 3 | 4 | "hours" Reference Manual 5 | 6 | "hours" has 6 views: 7 | - Tasks List View Shows active tasks 8 | - Task Management View Shows a form to create/update tasks 9 | - Task Logs List View Shows your task logs 10 | - Task Log Details View Shows details for a task log 11 | - Inactive Tasks List View Shows inactive tasks 12 | - Task Log Entry View Shows a form to save/update a task log entry 13 | - Help View (this one) 14 | 15 | Keyboard Shortcuts 16 | 17 | General 18 | 19 | 1 Switch to Tasks List View 20 | 2 Switch to Task Logs List View 21 | 3 Switch to Inactive Tasks List View 22 | Go to next view/form entry 23 | Go to previous view/form entry 24 | q/ Go back or quit 25 | Quit immediately 26 | ? Show help view 27 | 28 | General List Controls 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /internal/ui/__snapshots__/TestTaskLogViewWithEntries_1.snap: -------------------------------------------------------------------------------- 1 | 2 | Task Logs (last 50) 3 | 4 | 3 entries 5 | 6 | │ Test work on task 7 | │ Implement feature A 06:30 ... 08:00 … 8 | 9 | Test work on task 10 | Fix bug in module B 06:30 ... 08:00 … 11 | 12 | Test work on task 13 | Implement feature A 06:30 ... 08:00 … 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | hours Press ? for help -------------------------------------------------------------------------------- /internal/types/duration_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestParseTaskLogTimes(t *testing.T) { 11 | testCases := []struct { 12 | name string 13 | beginStr string 14 | endStr string 15 | err error 16 | }{ 17 | // Successes 18 | { 19 | name: "valid times - less than an hour", 20 | beginStr: "2025/08/08 00:40", 21 | endStr: "2025/08/08 00:48", 22 | }, 23 | { 24 | name: "valid times - exact hour", 25 | beginStr: "2025/08/08 00:00", 26 | endStr: "2025/08/08 01:00", 27 | }, 28 | { 29 | name: "valid times - hours and minutes", 30 | beginStr: "2025/08/08 00:00", 31 | endStr: "2025/08/08 02:30", 32 | }, 33 | { 34 | name: "valid times - across day boundary", 35 | beginStr: "2025/08/08 23:30", 36 | endStr: "2025/08/09 00:15", 37 | }, 38 | { 39 | name: "valid times - exactly at 8h", 40 | beginStr: "2025/08/08 00:00", 41 | endStr: "2025/08/08 08:00", 42 | }, 43 | { 44 | name: "valid times - very long duration", 45 | beginStr: "2025/08/08 00:00", 46 | endStr: "2025/08/09 02:00", 47 | }, 48 | { 49 | name: "valid times - exactly one minute", 50 | beginStr: "2025/08/08 00:00", 51 | endStr: "2025/08/08 00:01", 52 | }, 53 | // Failures 54 | { 55 | name: "empty begin time", 56 | beginStr: "", 57 | endStr: "2025/08/08 00:10", 58 | err: errBeginTimeIsEmpty, 59 | }, 60 | { 61 | name: "empty end time", 62 | beginStr: "2025/08/08 00:10", 63 | endStr: "", 64 | err: errEndTimeIsEmpty, 65 | }, 66 | { 67 | name: "begin time as whitespace only", 68 | beginStr: " ", 69 | endStr: "2025/08/08 00:10", 70 | err: errBeginTimeIsEmpty, 71 | }, 72 | { 73 | name: "end time as whitespace only", 74 | beginStr: "2025/08/08 00:10", 75 | endStr: " ", 76 | err: errEndTimeIsEmpty, 77 | }, 78 | { 79 | name: "invalid begin time format", 80 | beginStr: "2025-08-08 00:10", 81 | endStr: "2025/08/08 00:20", 82 | err: errBeginTimeIsInvalid, 83 | }, 84 | { 85 | name: "invalid end time format", 86 | beginStr: "2025/08/08 00:10", 87 | endStr: "08-08-2025 00:20", 88 | err: errEndTimeIsInvalid, 89 | }, 90 | { 91 | name: "end time before begin time", 92 | beginStr: "2025/08/08 01:00", 93 | endStr: "2025/08/08 00:59", 94 | err: errEndTimeBeforeBeginTime, 95 | }, 96 | { 97 | name: "zero duration", 98 | beginStr: "2025/08/08 00:00", 99 | endStr: "2025/08/08 00:00", 100 | err: ErrDurationNotLongEnough, 101 | }, 102 | } 103 | 104 | for _, tt := range testCases { 105 | t.Run(tt.name, func(t *testing.T) { 106 | beginTS, endTS, err := ParseTaskLogTimes(tt.beginStr, tt.endStr) 107 | 108 | if tt.err != nil { 109 | require.ErrorIs(t, err, tt.err) 110 | } else { 111 | require.NoError(t, err) 112 | assert.False(t, beginTS.IsZero()) 113 | assert.False(t, endTS.IsZero()) 114 | } 115 | }) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cat <<'EOF' 4 | _ 5 | | |__ ___ _ _ _ __ ___ 6 | | '_ \ / _ \| | | | '__/ __| 7 | | | | | (_) | |_| | | \__ \ 8 | |_| |_|\___/ \__,_|_| |___/ 9 | 10 | EOF 11 | pass_count=0 12 | fail_count=0 13 | 14 | temp_dir=$(mktemp -d) 15 | db_file_path="${temp_dir}/db.db" 16 | 17 | echo "hours gen -y -d ${db_file_path}" 18 | hours gen -y -d "${db_file_path}" 19 | 20 | tests=( 21 | "log: today|hours -d ${db_file_path} log -p today|0" 22 | "log: yest|hours -d ${db_file_path} log -p yest|0" 23 | "log: 3d|hours -d ${db_file_path} log -p 3d|0" 24 | "log: week|hours -d ${db_file_path} log -p week|0" 25 | "log: date|hours -d ${db_file_path} log -p 2024/06/08|0" 26 | "log: date range|hours -d ${db_file_path} log -p 2024/06/08...2024/06/12|0" 27 | "log: incorrect argument|hours -d ${db_file_path} log -p blah|1" 28 | "log: incorrect date|hours -d ${db_file_path} log -p 2024/0608|1" 29 | "log: incorrect date range|hours -d ${db_file_path} log -p 2024/0608...2024/06/12|1" 30 | "report: today|hours -d ${db_file_path} report -p today|0" 31 | "report: yest|hours -d ${db_file_path} report -p yest|0" 32 | "report: 3d|hours -d ${db_file_path} report -p 3d|0" 33 | "report: week|hours -d ${db_file_path} report -p week|0" 34 | "report: date|hours -d ${db_file_path} report -p 2024/06/08|0" 35 | "report: date range|hours -d ${db_file_path} report -p 2024/06/08...2024/06/12|0" 36 | "report: incorrect argument|hours -d ${db_file_path} report -p blah|1" 37 | "report: incorrect date|hours -d ${db_file_path} report -p 2024/0608|1" 38 | "report: incorrect date range|hours -d ${db_file_path} report -p 2024/0608...2024/06/12|1" 39 | "report: date range too large|hours -d ${db_file_path} report -p 2024/06/08...2024/06/15|1" 40 | "stats: today|hours -d ${db_file_path} stats -p today|0" 41 | "stats: yest|hours -d ${db_file_path} stats -p yest|0" 42 | "stats: 3d|hours -d ${db_file_path} stats -p 3d|0" 43 | "stats: week|hours -d ${db_file_path} stats -p week|0" 44 | "stats: date|hours -d ${db_file_path} stats -p 2024/06/08|0" 45 | "stats: date range|hours -d ${db_file_path} stats -p 2024/06/08...2024/06/12|0" 46 | "stats: all|hours -d ${db_file_path} stats -p all|0" 47 | "stats: incorrect argument|hours -d ${db_file_path} stats -p blah|1" 48 | "stats: incorrect date|hours -d ${db_file_path} stats -p 2024/0608|1" 49 | "stats: incorrect date range|hours -d ${db_file_path} stats -p 2024/0608...2024/06/12|1" 50 | ) 51 | 52 | for test in "${tests[@]}"; do 53 | IFS='|' read -r title cmd expected_exit_code <<<"$test" 54 | 55 | echo "> $title" 56 | echo "$cmd" 57 | echo 58 | eval "$cmd" 59 | exit_code=$? 60 | if [ $exit_code -eq $expected_exit_code ]; then 61 | echo "✅ command behaves as expected" 62 | ((pass_count++)) 63 | else 64 | echo "❌ command returned $exit_code, expected $expected_exit_code" 65 | ((fail_count++)) 66 | fi 67 | echo 68 | echo "===============================" 69 | echo 70 | done 71 | 72 | echo "Summary:" 73 | echo "- Passed: $pass_count" 74 | echo "- Failed: $fail_count" 75 | 76 | if [ $fail_count -gt 0 ]; then 77 | exit 1 78 | else 79 | exit 0 80 | fi 81 | -------------------------------------------------------------------------------- /internal/persistence/migrations.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | const latestDBVersion = 1 // only upgrade this after adding a migration in getMigrations 11 | 12 | var ( 13 | ErrDBDowngraded = errors.New("database downgraded") 14 | ErrDBMigrationFailed = errors.New("database migration failed") 15 | ErrCouldntFetchDBVersion = errors.New("couldn't fetch version") 16 | ) 17 | 18 | type dbVersionInfo struct { 19 | id int 20 | version int 21 | createdAt time.Time 22 | } 23 | 24 | func getMigrations() map[int]string { 25 | migrations := make(map[int]string) 26 | // these migrations should not be modified once released. 27 | // that is, migrations is an append-only map. 28 | 29 | // migrations[2] = ` 30 | // ALTER TABLE task 31 | // ADD COLUMN new_col TEXT; 32 | // ` 33 | 34 | return migrations 35 | } 36 | 37 | func fetchLatestDBVersion(db *sql.DB) (dbVersionInfo, error) { 38 | row := db.QueryRow(` 39 | SELECT id, version, created_at 40 | FROM db_versions 41 | ORDER BY created_at DESC 42 | LIMIT 1; 43 | `) 44 | 45 | var dbVersion dbVersionInfo 46 | err := row.Scan( 47 | &dbVersion.id, 48 | &dbVersion.version, 49 | &dbVersion.createdAt, 50 | ) 51 | 52 | return dbVersion, err 53 | } 54 | 55 | func UpgradeDBIfNeeded(db *sql.DB) error { 56 | latestVersionInDB, err := fetchLatestDBVersion(db) 57 | if err != nil { 58 | return fmt.Errorf("%w: %s", ErrCouldntFetchDBVersion, err.Error()) 59 | } 60 | 61 | if latestVersionInDB.version > latestDBVersion { 62 | return fmt.Errorf("%w; debug info: version=%d, created at=%q)", 63 | ErrDBDowngraded, 64 | latestVersionInDB.version, 65 | latestVersionInDB.createdAt.Format(time.RFC3339), 66 | ) 67 | } 68 | 69 | if latestVersionInDB.version < latestDBVersion { 70 | err = UpgradeDB(db, latestVersionInDB.version) 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func UpgradeDB(db *sql.DB, currentVersion int) error { 80 | migrations := getMigrations() 81 | for i := currentVersion + 1; i <= latestDBVersion; i++ { 82 | migrateQuery := migrations[i] 83 | migrateErr := runMigration(db, migrateQuery, i) 84 | if migrateErr != nil { 85 | return fmt.Errorf("%w (version %d): %v", ErrDBMigrationFailed, i, migrateErr.Error()) 86 | } 87 | } 88 | return nil 89 | } 90 | 91 | func runMigration(db *sql.DB, migrateQuery string, version int) error { 92 | tx, err := db.Begin() 93 | if err != nil { 94 | return err 95 | } 96 | defer func() { 97 | _ = tx.Rollback() 98 | }() 99 | 100 | stmt, err := tx.Prepare(migrateQuery) 101 | if err != nil { 102 | return err 103 | } 104 | defer stmt.Close() 105 | 106 | _, err = stmt.Exec() 107 | if err != nil { 108 | return err 109 | } 110 | 111 | tStmt, err := tx.Prepare(` 112 | INSERT INTO db_versions (version, created_at) 113 | VALUES (?, ?); 114 | `) 115 | if err != nil { 116 | return err 117 | } 118 | defer tStmt.Close() 119 | 120 | _, err = tStmt.Exec(version, time.Now().UTC()) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | err = tx.Commit() 126 | if err != nil { 127 | return err 128 | } 129 | 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /internal/ui/generate.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "math/rand" 7 | "time" 8 | 9 | pers "github.com/dhth/hours/internal/persistence" 10 | ) 11 | 12 | const ( 13 | nonEmptyCommentChance = 0.8 14 | longCommentChance = 0.3 15 | sampleLongCommentBody = ` 16 | 17 | This is a sample task log comment. The comment can be used to record 18 | additional information for a task log. 19 | 20 | You can include: 21 | - Detailed steps taken during the task 22 | - Observations and notes 23 | - Any issues encountered and how they were resolved 24 | - Future actions or follow-ups required 25 | - References to related tasks or documents 26 | 27 | Use this section to ensure all relevant details are captured for each task, 28 | providing a comprehensive log that can be referred to later.` 29 | ) 30 | 31 | var ( 32 | tasks = []string{ 33 | ".net", 34 | "assembly", 35 | "c", 36 | "c#", 37 | "c++", 38 | "clojure", 39 | "dart", 40 | "elixir", 41 | "erlang", 42 | "f#", 43 | "go", 44 | "haskell", 45 | "java", 46 | "javascript", 47 | "julia", 48 | "kotlin", 49 | "lisp", 50 | "lua", 51 | "ocaml", 52 | "objective-c", 53 | "php", 54 | "perl", 55 | "prolog", 56 | "python", 57 | "r", 58 | "roc", 59 | "ruby", 60 | "rust", 61 | "sql", 62 | "scala", 63 | "swift", 64 | "typescript", 65 | "zig", 66 | } 67 | verbs = []string{ 68 | "write", 69 | "fix", 70 | "deploy", 71 | "review", 72 | "test", 73 | "refactor", 74 | "design", 75 | "implement", 76 | "document", 77 | "update", 78 | "create", 79 | "analyze", 80 | "optimize", 81 | "integrate", 82 | "configure", 83 | "build", 84 | "debug", 85 | "monitor", 86 | "automate", 87 | "maintain", 88 | } 89 | nouns = []string{ 90 | "documentation", 91 | "tests", 92 | "code", 93 | "review", 94 | "feature", 95 | "bug", 96 | "module", 97 | "api", 98 | "interface", 99 | "function", 100 | "pipeline", 101 | "database", 102 | "service", 103 | "deployment", 104 | "configuration", 105 | "component", 106 | "report", 107 | "script", 108 | "workflow", 109 | "log", 110 | } 111 | ) 112 | 113 | func GenerateData(db *sql.DB, numDays, numTasks uint8) error { 114 | for i := range numTasks { 115 | summary := tasks[rand.Intn(len(tasks))] 116 | _, err := pers.InsertTask(db, summary) 117 | if err != nil { 118 | return err 119 | } 120 | numLogs := int(numDays/2) + rand.Intn(int(numDays/2)) 121 | for range numLogs { 122 | beginTs := randomTimestamp(int(numDays)) 123 | numMinutes := 30 + rand.Intn(60) 124 | endTs := beginTs.Add(time.Minute * time.Duration(numMinutes)) 125 | var comment *string 126 | commentStr := fmt.Sprintf("%s %s", verbs[rand.Intn(len(verbs))], nouns[rand.Intn(len(nouns))]) 127 | if rand.Float64() < nonEmptyCommentChance { 128 | if rand.Float64() < longCommentChance { 129 | commentStr += sampleLongCommentBody 130 | } 131 | comment = &commentStr 132 | } 133 | 134 | _, err = pers.InsertManualTL(db, int(i+1), beginTs, endTs, comment) 135 | if err != nil { 136 | return err 137 | } 138 | } 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func randomTimestamp(numDays int) time.Time { 145 | now := time.Now().Local() 146 | 147 | maxSeconds := numDays * 24 * 60 * 60 148 | randomSeconds := rand.Intn(maxSeconds) 149 | randomTime := now.Add(-time.Duration(randomSeconds) * time.Second) 150 | return randomTime 151 | } 152 | -------------------------------------------------------------------------------- /internal/ui/theme/theme_test.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | _ "embed" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/gkampitakis/go-snaps/snaps" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | //go:embed testdata/valid-with-entire-config.json 14 | var validThemeWithEntireConfig []byte 15 | 16 | //go:embed testdata/valid-with-partial-config.json 17 | var validThemeWithPartialConfig []byte 18 | 19 | //go:embed testdata/invalid-with-entire-config.json 20 | var invalidThemeWithEntireConfig []byte 21 | 22 | //go:embed testdata/malformed-json.json 23 | var invalidThemeMalformedJSON []byte 24 | 25 | //go:embed testdata/invalid-schema.json 26 | var invalidThemeInvalidSchema []byte 27 | 28 | //go:embed testdata/invalid-data.json 29 | var invalidThemeInvalidData []byte 30 | 31 | func TestGetInvalidColors(t *testing.T) { 32 | testCases := []struct { 33 | name string 34 | themeBytes []byte 35 | expectedNumInvalid int 36 | }{ 37 | // success 38 | { 39 | name: "valid json with all key-values provided", 40 | themeBytes: validThemeWithEntireConfig, 41 | expectedNumInvalid: 0, 42 | }, 43 | // failures 44 | { 45 | name: "invalid data", 46 | themeBytes: invalidThemeInvalidData, 47 | expectedNumInvalid: 5, 48 | }, 49 | { 50 | name: "invalid data with entire config", 51 | themeBytes: invalidThemeWithEntireConfig, 52 | expectedNumInvalid: 42, 53 | }, 54 | } 55 | 56 | for _, tt := range testCases { 57 | t.Run(tt.name, func(t *testing.T) { 58 | // GIVEN 59 | defaultTheme := Default() 60 | err := json.Unmarshal(tt.themeBytes, &defaultTheme) 61 | require.NoError(t, err) 62 | // WHEN 63 | invalidColors := getInvalidColors(defaultTheme) 64 | 65 | // THEN 66 | assert.Len(t, invalidColors, tt.expectedNumInvalid) 67 | }) 68 | } 69 | } 70 | 71 | func TestLoadCustomLoadsFullThemeCorrectly(t *testing.T) { 72 | // GIVEN 73 | // WHEN 74 | customTheme, err := loadCustom(validThemeWithEntireConfig) 75 | 76 | // THEN 77 | require.NoError(t, err) 78 | snaps.MatchStandaloneYAML(t, customTheme) 79 | } 80 | 81 | func TestLoadCustomPartialThemeCorrectly(t *testing.T) { 82 | // GIVEN 83 | // WHEN 84 | customTheme, err := loadCustom(validThemeWithPartialConfig) 85 | 86 | // THEN 87 | require.NoError(t, err) 88 | snaps.MatchStandaloneYAML(t, customTheme) 89 | } 90 | 91 | func TestLoadCustomHandlesFailuresCorrectly(t *testing.T) { 92 | testCases := []struct { 93 | name string 94 | input []byte 95 | err error 96 | }{ 97 | // success 98 | { 99 | name: "valid json with all key-values provided", 100 | input: validThemeWithEntireConfig, 101 | }, 102 | { 103 | name: "valid json with some key-values provided", 104 | input: validThemeWithPartialConfig, 105 | }, 106 | // failures 107 | { 108 | name: "malformed json", 109 | input: invalidThemeMalformedJSON, 110 | err: errThemeFileIsInvalidJSON, 111 | }, 112 | { 113 | name: "invalid schema", 114 | input: invalidThemeInvalidSchema, 115 | err: ErrThemeFileHasInvalidSchema, 116 | }, 117 | { 118 | name: "invalid data", 119 | input: invalidThemeInvalidData, 120 | err: ErrThemeColorsAreInvalid, 121 | }, 122 | } 123 | 124 | for _, tt := range testCases { 125 | t.Run(tt.name, func(t *testing.T) { 126 | // GIVEN 127 | // WHEN 128 | _, err := loadCustom(tt.input) 129 | 130 | // THEN 131 | assert.ErrorIs(t, err, tt.err) 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /internal/ui/help.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "fmt" 4 | 5 | func getHelpText(style Style) string { 6 | return fmt.Sprintf(`%s 7 | %s 8 | %s 9 | 10 | %s 11 | %s 12 | %s 13 | %s 14 | %s 15 | %s 16 | %s 17 | %s 18 | %s 19 | %s 20 | %s 21 | %s 22 | %s 23 | %s`, 24 | style.helpPrimary.Render("\"hours\" Reference Manual"), 25 | style.helpSecondary.Render(` 26 | "hours" has 6 views: 27 | - Tasks List View Shows active tasks 28 | - Task Management View Shows a form to create/update tasks 29 | - Task Logs List View Shows your task logs 30 | - Task Log Details View Shows details for a task log 31 | - Inactive Tasks List View Shows inactive tasks 32 | - Task Log Entry View Shows a form to save/update a task log entry 33 | - Help View (this one) 34 | `), 35 | style.helpPrimary.Render("Keyboard Shortcuts"), 36 | style.helpPrimary.Render("General"), 37 | style.helpSecondary.Render(` 38 | 1 Switch to Tasks List View 39 | 2 Switch to Task Logs List View 40 | 3 Switch to Inactive Tasks List View 41 | Go to next view/form entry 42 | Go to previous view/form entry 43 | q/ Go back or quit 44 | Quit immediately 45 | ? Show help view 46 | `), 47 | style.helpPrimary.Render("General List Controls"), 48 | style.helpSecondary.Render(` 49 | k/ Move cursor up 50 | j/ Move cursor down 51 | h Go to previous page 52 | l Go to next page 53 | Refresh list 54 | `), 55 | style.helpPrimary.Render("Task List View"), 56 | style.helpSecondary.Render(` 57 | a Add a task 58 | u Update task details 59 | s Start/stop recording time on a task; stopping 60 | will open up the "Task Log Entry View" 61 | S Quick switch recording; will save a task log 62 | entry for the currently active task, and 63 | start recording time for another 64 | f Finish the currently active task log without 65 | comment 66 | Edit the currently active task log/Add a new 67 | manual task log entry 68 | Discard currently active recording 69 | Go to currently tracked item 70 | Deactivate task 71 | `), 72 | style.helpPrimary.Render("Task Logs List View"), 73 | style.helpSecondary.Render(` 74 | ~ at the end of a task log comment indicates that it has more lines that are not 75 | visible in the list view 76 | 77 | d Show task log details 78 | /u Update task log entry 79 | Delete task log entry 80 | `), 81 | style.helpPrimary.Render("Task Log Details View"), 82 | style.helpSecondary.Render(` 83 | h Go to previous entry 84 | l Go to next entry 85 | `), 86 | style.helpPrimary.Render("Inactive Task List View"), 87 | style.helpSecondary.Render(` 88 | Activate task 89 | `), 90 | style.helpPrimary.Render("Task Log Entry View"), 91 | style.helpSecondary.Render(` 92 | enter/ Save entered details for the task log 93 | k Move timestamp backwards by one minute 94 | j Move timestamp forwards by one minute 95 | K Move timestamp backwards by five minutes 96 | J Move timestamp forwards by five minutes 97 | h Move timestamp backwards by a day 98 | l Move timestamp forwards by a day 99 | `), 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /internal/ui/stats.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "io" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | pers "github.com/dhth/hours/internal/persistence" 13 | "github.com/dhth/hours/internal/types" 14 | "github.com/dhth/hours/internal/utils" 15 | "github.com/olekukonko/tablewriter" 16 | "github.com/olekukonko/tablewriter/renderer" 17 | "github.com/olekukonko/tablewriter/tw" 18 | ) 19 | 20 | var errCouldntGenerateStats = errors.New("couldn't generate stats") 21 | 22 | const ( 23 | statsLogEntriesLimit = 10000 24 | statsTimeCharsBudget = 6 25 | ) 26 | 27 | func RenderStats(db *sql.DB, 28 | style Style, 29 | writer io.Writer, 30 | plain bool, 31 | dateRange *types.DateRange, 32 | period string, 33 | taskStatus types.TaskStatus, 34 | interactive bool, 35 | ) error { 36 | var stats string 37 | var err error 38 | 39 | if interactive && dateRange == nil { 40 | return fmt.Errorf("%w when period=all", errInteractiveModeNotApplicable) 41 | } 42 | 43 | if dateRange == nil { 44 | stats, err = getStats(db, style, dateRange, taskStatus, plain) 45 | if err != nil { 46 | return fmt.Errorf("%w: %s", errCouldntGenerateStats, err.Error()) 47 | } 48 | 49 | fmt.Fprint(writer, stats) 50 | return nil 51 | } 52 | 53 | stats, err = getStats(db, style, dateRange, taskStatus, plain) 54 | if err != nil { 55 | return fmt.Errorf("%w: %s", errCouldntGenerateStats, err.Error()) 56 | } 57 | 58 | if interactive { 59 | p := tea.NewProgram(initialRecordsModel( 60 | reportStats, 61 | db, 62 | style, 63 | types.RealTimeProvider{}, 64 | *dateRange, 65 | period, 66 | taskStatus, 67 | plain, 68 | stats, 69 | )) 70 | _, err := p.Run() 71 | if err != nil { 72 | return err 73 | } 74 | } else { 75 | fmt.Fprint(writer, stats) 76 | } 77 | return nil 78 | } 79 | 80 | func getStats(db *sql.DB, 81 | style Style, 82 | dateRange *types.DateRange, 83 | taskStatus types.TaskStatus, 84 | plain bool) (string, 85 | error, 86 | ) { 87 | var entries []types.TaskReportEntry 88 | var err error 89 | 90 | if dateRange == nil { 91 | entries, err = pers.FetchStats(db, taskStatus, statsLogEntriesLimit) 92 | } else { 93 | entries, err = pers.FetchStatsBetweenTS(db, dateRange.Start, dateRange.End, taskStatus, statsLogEntriesLimit) 94 | } 95 | 96 | if err != nil { 97 | return "", err 98 | } 99 | 100 | var numEntriesInTable int 101 | if len(entries) == 0 { 102 | numEntriesInTable = 1 103 | } else { 104 | numEntriesInTable = len(entries) 105 | } 106 | 107 | data := make([][]string, numEntriesInTable) 108 | if len(entries) == 0 { 109 | data[0] = []string{ 110 | utils.RightPadTrim("", 20, false), 111 | "", 112 | utils.RightPadTrim("", statsTimeCharsBudget, false), 113 | } 114 | } 115 | 116 | var timeSpentStr string 117 | 118 | rs := style.getReportStyles(plain) 119 | styleCache := make(map[string]lipgloss.Style) 120 | 121 | for i, entry := range entries { 122 | timeSpentStr = types.HumanizeDuration(entry.SecsSpent) 123 | 124 | if plain { 125 | data[i] = []string{ 126 | utils.RightPadTrim(entry.TaskSummary, 20, false), 127 | fmt.Sprintf("%d", entry.NumEntries), 128 | utils.RightPadTrim(timeSpentStr, statsTimeCharsBudget, false), 129 | } 130 | } else { 131 | rowStyle, ok := styleCache[entry.TaskSummary] 132 | if !ok { 133 | rowStyle = style.getDynamicStyle(entry.TaskSummary) 134 | styleCache[entry.TaskSummary] = rowStyle 135 | } 136 | data[i] = []string{ 137 | rowStyle.Render(utils.RightPadTrim(entry.TaskSummary, 20, false)), 138 | rowStyle.Render(fmt.Sprintf("%d", entry.NumEntries)), 139 | rowStyle.Render(utils.RightPadTrim(timeSpentStr, statsTimeCharsBudget, false)), 140 | } 141 | } 142 | } 143 | 144 | headerValues := []string{"Task", "#LogEntries", "TimeSpent"} 145 | headers := make([]string, len(headerValues)) 146 | for i, h := range headerValues { 147 | headers[i] = rs.headerStyle.Render(h) 148 | } 149 | b := bytes.Buffer{} 150 | table := tablewriter.NewTable(&b, 151 | tablewriter.WithConfig(tablewriter.Config{ 152 | Header: tw.CellConfig{ 153 | Formatting: tw.CellFormatting{ 154 | Alignment: tw.AlignCenter, 155 | AutoWrap: tw.WrapNone, 156 | AutoFormat: tw.Off, 157 | }, 158 | }, 159 | Row: tw.CellConfig{ 160 | Formatting: tw.CellFormatting{ 161 | Alignment: tw.AlignLeft, 162 | AutoWrap: tw.WrapNone, 163 | }, 164 | }, 165 | }), 166 | tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{Symbols: rs.symbols(tw.StyleASCII)})), 167 | tablewriter.WithHeader(headers), 168 | ) 169 | 170 | if err := table.Bulk(data); err != nil { 171 | return "", fmt.Errorf("%w: %s", errCouldntAddDataToTable, err.Error()) 172 | } 173 | 174 | if err := table.Render(); err != nil { 175 | return "", fmt.Errorf("%w: %s", errCouldntRenderTable, err.Error()) 176 | } 177 | 178 | return b.String(), nil 179 | } 180 | -------------------------------------------------------------------------------- /internal/ui/log.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "time" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | pers "github.com/dhth/hours/internal/persistence" 14 | "github.com/dhth/hours/internal/types" 15 | "github.com/dhth/hours/internal/utils" 16 | "github.com/olekukonko/tablewriter" 17 | "github.com/olekukonko/tablewriter/renderer" 18 | "github.com/olekukonko/tablewriter/tw" 19 | ) 20 | 21 | const ( 22 | logTimeCharsBudget = 6 23 | interactiveLogDayLimit = 1 24 | logLimit = 10000 25 | ) 26 | 27 | var errCouldntGenerateLogs = errors.New("couldn't generate logs") 28 | 29 | func RenderTaskLog(db *sql.DB, 30 | style Style, 31 | writer io.Writer, 32 | plain bool, 33 | dateRange types.DateRange, 34 | period string, 35 | taskStatus types.TaskStatus, 36 | interactive bool, 37 | ) error { 38 | if interactive && dateRange.NumDays > interactiveLogDayLimit { 39 | return fmt.Errorf("%w (limited to %d day); use non-interactive mode to see logs for a larger time period", errInteractiveModeNotApplicable, interactiveLogDayLimit) 40 | } 41 | 42 | log, err := getTaskLog(db, style, dateRange.Start, dateRange.End, taskStatus, logLimit, plain) 43 | if err != nil { 44 | return fmt.Errorf("%w: %s", errCouldntGenerateLogs, err.Error()) 45 | } 46 | 47 | if interactive { 48 | p := tea.NewProgram(initialRecordsModel( 49 | reportLogs, 50 | db, 51 | style, 52 | types.RealTimeProvider{}, 53 | dateRange, 54 | period, 55 | taskStatus, 56 | plain, 57 | log, 58 | )) 59 | _, err := p.Run() 60 | if err != nil { 61 | return err 62 | } 63 | } else { 64 | fmt.Fprint(writer, log) 65 | } 66 | return nil 67 | } 68 | 69 | func getTaskLog(db *sql.DB, 70 | style Style, 71 | start, 72 | end time.Time, 73 | taskStatus types.TaskStatus, 74 | limit int, 75 | plain bool) (string, 76 | error, 77 | ) { 78 | entries, err := pers.FetchTLEntriesBetweenTS(db, start, end, taskStatus, limit) 79 | if err != nil { 80 | return "", err 81 | } 82 | 83 | var numEntriesInTable int 84 | 85 | if len(entries) == 0 { 86 | numEntriesInTable = 1 87 | } else { 88 | numEntriesInTable = len(entries) 89 | } 90 | 91 | data := make([][]string, numEntriesInTable) 92 | 93 | if len(entries) == 0 { 94 | data[0] = []string{ 95 | utils.RightPadTrim("", 20, false), 96 | utils.RightPadTrim("", 40, false), 97 | utils.RightPadTrim("", 39, false), 98 | utils.RightPadTrim("", logTimeCharsBudget, false), 99 | } 100 | } 101 | 102 | var timeSpentStr string 103 | 104 | rs := style.getReportStyles(plain) 105 | styleCache := make(map[string]lipgloss.Style) 106 | 107 | for i, entry := range entries { 108 | timeSpentStr = types.HumanizeDuration(entry.SecsSpent) 109 | 110 | if plain { 111 | data[i] = []string{ 112 | utils.RightPadTrim(entry.TaskSummary, 20, false), 113 | utils.RightPadTrimWithMoreLinesIndicator(entry.GetComment(), 40), 114 | fmt.Sprintf("%s ... %s", entry.BeginTS.Format(timeFormat), entry.EndTS.Format(timeFormat)), 115 | utils.RightPadTrim(timeSpentStr, logTimeCharsBudget, false), 116 | } 117 | } else { 118 | rowStyle, ok := styleCache[entry.TaskSummary] 119 | if !ok { 120 | rowStyle = style.getDynamicStyle(entry.TaskSummary) 121 | styleCache[entry.TaskSummary] = rowStyle 122 | } 123 | data[i] = []string{ 124 | rowStyle.Render(utils.RightPadTrim(entry.TaskSummary, 20, false)), 125 | rowStyle.Render(utils.RightPadTrimWithMoreLinesIndicator(entry.GetComment(), 40)), 126 | rowStyle.Render(fmt.Sprintf("%s ... %s", entry.BeginTS.Format(timeFormat), entry.EndTS.Format(timeFormat))), 127 | rowStyle.Render(utils.RightPadTrim(timeSpentStr, logTimeCharsBudget, false)), 128 | } 129 | } 130 | } 131 | 132 | headerValues := []string{"Task", "Comment", "Duration", "TimeSpent"} 133 | headers := make([]string, len(headerValues)) 134 | for i, h := range headerValues { 135 | headers[i] = rs.headerStyle.Render(h) 136 | } 137 | 138 | b := bytes.Buffer{} 139 | table := tablewriter.NewTable(&b, 140 | tablewriter.WithConfig(tablewriter.Config{ 141 | Header: tw.CellConfig{ 142 | Formatting: tw.CellFormatting{ 143 | Alignment: tw.AlignCenter, 144 | AutoWrap: tw.WrapNone, 145 | AutoFormat: tw.Off, 146 | }, 147 | }, 148 | Row: tw.CellConfig{ 149 | Formatting: tw.CellFormatting{ 150 | Alignment: tw.AlignLeft, 151 | AutoWrap: tw.WrapNone, 152 | }, 153 | }, 154 | }), 155 | tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{Symbols: rs.symbols(tw.StyleASCII)})), 156 | tablewriter.WithHeader(headers), 157 | ) 158 | 159 | if err := table.Bulk(data); err != nil { 160 | return "", fmt.Errorf("%w: %s", errCouldntAddDataToTable, err.Error()) 161 | } 162 | 163 | if err := table.Render(); err != nil { 164 | return "", fmt.Errorf("%w: %s", errCouldntRenderTable, err.Error()) 165 | } 166 | 167 | return b.String(), nil 168 | } 169 | -------------------------------------------------------------------------------- /internal/ui/initial.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/charmbracelet/bubbles/list" 7 | "github.com/charmbracelet/bubbles/textarea" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/dhth/hours/internal/types" 11 | ) 12 | 13 | const ( 14 | tlCommentLengthLimit = 3000 15 | textInputWidth = 80 16 | ) 17 | 18 | func InitialModel(db *sql.DB, 19 | style Style, 20 | timeProvider types.TimeProvider, 21 | debug bool, 22 | logFramesCfg logFramesConfig, 23 | ) Model { 24 | var activeTaskItems []list.Item 25 | var inactiveTaskItems []list.Item 26 | var tasklogListItems []list.Item 27 | 28 | tLInputs := make([]textinput.Model, 2) 29 | tLInputs[entryBeginTS] = textinput.New() 30 | tLInputs[entryBeginTS].Placeholder = "09:30" 31 | tLInputs[entryBeginTS].CharLimit = len(timeFormat) 32 | tLInputs[entryBeginTS].Width = 30 33 | 34 | tLInputs[entryEndTS] = textinput.New() 35 | tLInputs[entryEndTS].Placeholder = "12:30pm" 36 | tLInputs[entryEndTS].CharLimit = len(timeFormat) 37 | tLInputs[entryEndTS].Width = 30 38 | 39 | tLCommentInput := textarea.New() 40 | tLCommentInput.Placeholder = `Task log comment goes here. 41 | 42 | This can be used to record details about your work on this task.` 43 | tLCommentInput.CharLimit = tlCommentLengthLimit 44 | tLCommentInput.SetWidth(textInputWidth) 45 | tLCommentInput.SetHeight(10) 46 | tLCommentInput.ShowLineNumbers = false 47 | tLCommentInput.Prompt = " ┃ " 48 | 49 | taskInputs := make([]textinput.Model, 1) 50 | taskInputs[summaryField] = textinput.New() 51 | taskInputs[summaryField].Placeholder = "task summary goes here" 52 | taskInputs[summaryField].Focus() 53 | taskInputs[summaryField].CharLimit = 100 54 | taskInputs[entryBeginTS].Width = textInputWidth 55 | 56 | m := Model{ 57 | db: db, 58 | style: style, 59 | timeProvider: timeProvider, 60 | activeTasksList: list.New(activeTaskItems, 61 | newItemDelegate(style.listItemTitleColor, 62 | style.listItemDescColor, 63 | lipgloss.Color(style.theme.ActiveTasks), 64 | ), listWidth, 0), 65 | inactiveTasksList: list.New(inactiveTaskItems, 66 | newItemDelegate(style.listItemTitleColor, 67 | style.listItemDescColor, 68 | lipgloss.Color(style.theme.InactiveTasks), 69 | ), listWidth, 0), 70 | taskMap: make(map[int]*types.Task), 71 | taskIndexMap: make(map[int]int), 72 | taskLogList: list.New(tasklogListItems, 73 | newItemDelegate(style.listItemTitleColor, 74 | style.listItemDescColor, 75 | lipgloss.Color(style.theme.TaskLogList), 76 | ), listWidth, 0), 77 | showHelpIndicator: true, 78 | tLInputs: tLInputs, 79 | tLCommentInput: tLCommentInput, 80 | taskInputs: taskInputs, 81 | debug: debug, 82 | logFramesCfg: logFramesCfg, 83 | } 84 | m.activeTasksList.Title = "Tasks" 85 | m.activeTasksList.SetStatusBarItemName("task", "tasks") 86 | m.activeTasksList.DisableQuitKeybindings() 87 | m.activeTasksList.SetShowHelp(false) 88 | m.activeTasksList.Styles.Title = m.activeTasksList.Styles.Title. 89 | Foreground(lipgloss.Color(style.theme.TitleForeground)). 90 | Background(lipgloss.Color(style.theme.ActiveTasks)). 91 | Bold(true) 92 | m.activeTasksList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") 93 | m.activeTasksList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") 94 | 95 | m.taskLogList.Title = "Task Logs (last 50)" 96 | m.taskLogList.SetStatusBarItemName("entry", "entries") 97 | m.taskLogList.SetFilteringEnabled(false) 98 | m.taskLogList.DisableQuitKeybindings() 99 | m.taskLogList.SetShowHelp(false) 100 | m.taskLogList.Styles.Title = m.taskLogList.Styles.Title. 101 | Foreground(lipgloss.Color(style.theme.TitleForeground)). 102 | Background(lipgloss.Color(style.theme.TaskLogList)). 103 | Bold(true) 104 | m.taskLogList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") 105 | m.taskLogList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") 106 | 107 | m.inactiveTasksList.Title = "Inactive Tasks" 108 | m.inactiveTasksList.SetStatusBarItemName("task", "tasks") 109 | m.inactiveTasksList.DisableQuitKeybindings() 110 | m.inactiveTasksList.SetShowHelp(false) 111 | m.inactiveTasksList.Styles.Title = m.inactiveTasksList.Styles.Title. 112 | Foreground(lipgloss.Color(style.theme.TitleForeground)). 113 | Background(lipgloss.Color(style.theme.InactiveTasks)). 114 | Bold(true) 115 | m.inactiveTasksList.KeyMap.PrevPage.SetKeys("left", "h", "pgup") 116 | m.inactiveTasksList.KeyMap.NextPage.SetKeys("right", "l", "pgdown") 117 | 118 | return m 119 | } 120 | 121 | func initialRecordsModel( 122 | kind recordsKind, 123 | db *sql.DB, 124 | style Style, 125 | timeProvider types.TimeProvider, 126 | dateRange types.DateRange, 127 | period string, 128 | taskStatus types.TaskStatus, 129 | plain bool, 130 | initialData string, 131 | ) recordsModel { 132 | return recordsModel{ 133 | kind: kind, 134 | db: db, 135 | style: style, 136 | timeProvider: timeProvider, 137 | dateRange: dateRange, 138 | period: period, 139 | taskStatus: taskStatus, 140 | plain: plain, 141 | report: initialData, 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/types/date_helpers.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | const ( 11 | TimePeriodWeek = "week" 12 | timeFormat = "2006/01/02 15:04" 13 | timeOnlyFormat = "15:04" 14 | dayFormat = "Monday" 15 | dateFormat = "2006/01/02" 16 | ) 17 | 18 | var ( 19 | errDateRangeIncorrect = errors.New("date range is incorrect") 20 | errStartDateIncorrect = errors.New("start date is incorrect") 21 | errEndDateIncorrect = errors.New("end date is incorrect") 22 | errEndDateIsNotAfterStartDate = errors.New("end date is not after start date") 23 | errTimePeriodNotValid = errors.New("time period is not valid") 24 | errTimePeriodTooLarge = errors.New("time period is too large") 25 | ) 26 | 27 | func parseDateRange(rangeStr string, now time.Time) (DateRange, error) { 28 | var dr DateRange 29 | var err error 30 | 31 | elements := strings.Split(rangeStr, "...") 32 | if len(elements) != 2 { 33 | return dr, fmt.Errorf("%w: date range needs to be of the format: %s...%s (the second date can be left empty for today)", errDateRangeIncorrect, dateFormat, dateFormat) 34 | } 35 | 36 | start, err := time.ParseInLocation(string(dateFormat), elements[0], time.Local) 37 | if err != nil { 38 | return dr, fmt.Errorf("%w: %s", errStartDateIncorrect, err.Error()) 39 | } 40 | 41 | var end time.Time 42 | if elements[1] == "" || elements[1] == "today" { 43 | end = now 44 | } else { 45 | end, err = time.ParseInLocation(string(dateFormat), elements[1], time.Local) 46 | if err != nil { 47 | return dr, fmt.Errorf("%w: %s", errEndDateIncorrect, err.Error()) 48 | } 49 | } 50 | 51 | if end.Sub(start) <= 0 { 52 | return dr, fmt.Errorf("%w", errEndDateIsNotAfterStartDate) 53 | } 54 | 55 | return DateRange{ 56 | Start: start, 57 | End: end, 58 | NumDays: int(end.Sub(start).Hours()/24) + 1, 59 | }, nil 60 | } 61 | 62 | func GetDateRangeFromPeriod(period string, now time.Time, fullWeek bool, maxDaysAllowed *int) (DateRange, error) { 63 | var start, end time.Time 64 | var numDays int 65 | 66 | switch period { 67 | 68 | case "today": 69 | start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) 70 | end = start.AddDate(0, 0, 1) 71 | numDays = 1 72 | 73 | case "yest": 74 | aDayBefore := now.AddDate(0, 0, -1) 75 | start = time.Date(aDayBefore.Year(), aDayBefore.Month(), aDayBefore.Day(), 0, 0, 0, 0, aDayBefore.Location()) 76 | end = start.AddDate(0, 0, 1) 77 | numDays = 1 78 | 79 | case "3d": 80 | threeDaysBefore := now.AddDate(0, 0, -2) 81 | start = time.Date(threeDaysBefore.Year(), threeDaysBefore.Month(), threeDaysBefore.Day(), 0, 0, 0, 0, threeDaysBefore.Location()) 82 | end = start.AddDate(0, 0, 3) 83 | numDays = 3 84 | 85 | case TimePeriodWeek: 86 | weekday := now.Weekday() 87 | offset := (7 + weekday - time.Monday) % 7 88 | startOfWeek := now.AddDate(0, 0, -int(offset)) 89 | start = time.Date(startOfWeek.Year(), startOfWeek.Month(), startOfWeek.Day(), 0, 0, 0, 0, startOfWeek.Location()) 90 | if fullWeek { 91 | numDays = 7 92 | } else { 93 | numDays = int(offset) + 1 94 | } 95 | end = start.AddDate(0, 0, numDays) 96 | 97 | default: 98 | var err error 99 | 100 | if strings.Contains(period, "...") { 101 | var dr DateRange 102 | dr, err = parseDateRange(period, now) 103 | if err != nil { 104 | return dr, fmt.Errorf("%w: %s", errTimePeriodNotValid, err.Error()) 105 | } 106 | 107 | if maxDaysAllowed != nil && dr.NumDays > *maxDaysAllowed { 108 | return dr, fmt.Errorf("%w: maximum number of days allowed (both inclusive): %d", errTimePeriodTooLarge, *maxDaysAllowed) 109 | } 110 | 111 | start = dr.Start 112 | end = dr.End.AddDate(0, 0, 1) 113 | numDays = dr.NumDays 114 | } else { 115 | start, err = time.ParseInLocation(string(dateFormat), period, time.Local) 116 | if err != nil { 117 | return DateRange{}, fmt.Errorf("%w: %s", errTimePeriodNotValid, err.Error()) 118 | } 119 | end = start.AddDate(0, 0, 1) 120 | numDays = 1 121 | } 122 | } 123 | 124 | return DateRange{ 125 | Start: start, 126 | End: end, 127 | NumDays: numDays, 128 | }, nil 129 | } 130 | 131 | func GetShiftedTime(ts time.Time, direction TimeShiftDirection, duration TimeShiftDuration) time.Time { 132 | var d time.Duration 133 | 134 | switch duration { 135 | case ShiftMinute: 136 | d = time.Minute 137 | case ShiftFiveMinutes: 138 | d = time.Minute * 5 139 | case ShiftHour: 140 | d = time.Hour 141 | case ShiftDay: 142 | d = time.Hour * 24 143 | } 144 | 145 | if direction == ShiftBackward { 146 | d = -1 * d 147 | } 148 | return ts.Add(d) 149 | } 150 | 151 | type tsRelative uint8 152 | 153 | const ( 154 | tsFromFuture tsRelative = iota 155 | tsFromToday 156 | tsFromYesterday 157 | tsFromThisWeek 158 | tsFromBeforeThisWeek 159 | ) 160 | 161 | func getTSRelative(ts time.Time, reference time.Time) tsRelative { 162 | if ts.Sub(reference) > 0 { 163 | return tsFromFuture 164 | } 165 | 166 | startOfReferenceDay := time.Date(reference.Year(), reference.Month(), reference.Day(), 0, 0, 0, 0, reference.Location()) 167 | 168 | if ts.Sub(startOfReferenceDay) > 0 { 169 | return tsFromToday 170 | } 171 | 172 | startOfYest := startOfReferenceDay.AddDate(0, 0, -1) 173 | 174 | if ts.Sub(startOfYest) > 0 { 175 | return tsFromYesterday 176 | } 177 | 178 | weekday := reference.Weekday() 179 | offset := (7 + weekday - time.Monday) % 7 180 | startOfWeek := startOfReferenceDay.AddDate(0, 0, -int(offset)) 181 | 182 | if ts.Sub(startOfWeek) > 0 { 183 | return tsFromThisWeek 184 | } 185 | return tsFromBeforeThisWeek 186 | } 187 | -------------------------------------------------------------------------------- /internal/ui/model.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/list" 8 | "github.com/charmbracelet/bubbles/textarea" 9 | "github.com/charmbracelet/bubbles/textinput" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/dhth/hours/internal/types" 13 | ) 14 | 15 | type trackingChange uint 16 | 17 | const ( 18 | trackingStarted trackingChange = iota 19 | trackingFinished 20 | ) 21 | 22 | type stateView uint 23 | 24 | const ( 25 | taskListView stateView = iota // Main list of active tasks 26 | taskLogView // View showing task log entries 27 | taskLogDetailsView // Detailed view of a specific task log entry 28 | inactiveTaskListView // List of inactive tasks 29 | editActiveTLView // Form to edit currently active task log (ie, begin TS) 30 | finishActiveTLView // Form to finish active task log 31 | manualTasklogEntryView // Form to manually create a new task log entry 32 | editSavedTLView // Form to edit an existing task log 33 | taskInputView // Form to create or edit task details 34 | helpView // Help documentation view 35 | insufficientDimensionsView // Error view when terminal is too small 36 | ) 37 | 38 | type taskMgmtContext uint 39 | 40 | const ( 41 | taskCreateCxt taskMgmtContext = iota 42 | taskUpdateCxt 43 | ) 44 | 45 | type taskInputField uint 46 | 47 | const ( 48 | summaryField taskInputField = iota 49 | ) 50 | 51 | type tLTrackingFormField uint 52 | 53 | const ( 54 | entryBeginTS tLTrackingFormField = iota 55 | entryEndTS 56 | entryComment 57 | ) 58 | 59 | type tasklogSaveType uint 60 | 61 | type recordsKind uint 62 | 63 | const ( 64 | reportRecords recordsKind = iota 65 | reportAggRecords 66 | reportLogs 67 | reportStats 68 | ) 69 | 70 | const ( 71 | tasklogInsert tasklogSaveType = iota 72 | tasklogUpdate 73 | ) 74 | 75 | const ( 76 | timeFormat = "2006/01/02 15:04" 77 | timeOnlyFormat = "15:04" 78 | dateFormat = "2006/01/02" 79 | userMsgDefaultFrames = 3 80 | ) 81 | 82 | type userMsgKind uint 83 | 84 | const ( 85 | userMsgInfo userMsgKind = iota 86 | userMsgErr 87 | ) 88 | 89 | type userMsg struct { 90 | value string 91 | kind userMsgKind 92 | framesLeft uint 93 | } 94 | 95 | type logFramesConfig struct { 96 | log bool 97 | framesDir string 98 | } 99 | 100 | type Model struct { 101 | activeView stateView 102 | lastView stateView 103 | lastViewBeforeInsufficientDims stateView 104 | db *sql.DB 105 | style Style 106 | timeProvider types.TimeProvider 107 | activeTasksList list.Model 108 | inactiveTasksList list.Model 109 | taskMap map[int]*types.Task 110 | taskIndexMap map[int]int 111 | activeTLBeginTS time.Time 112 | activeTLEndTS time.Time 113 | activeTLComment *string 114 | tasksFetched bool 115 | taskLogList list.Model 116 | tLInputs []textinput.Model 117 | trackingFocussedField tLTrackingFormField 118 | tLCommentInput textarea.Model 119 | taskInputs []textinput.Model 120 | taskMgmtContext taskMgmtContext 121 | taskInputFocussedField taskInputField 122 | helpVP viewport.Model 123 | helpVPReady bool 124 | tLDetailsVP viewport.Model 125 | tLDetailsVPReady bool 126 | lastTrackingChange trackingChange 127 | changesLocked bool 128 | activeTaskID int 129 | tasklogSaveType tasklogSaveType 130 | message userMsg 131 | showHelpIndicator bool 132 | terminalWidth int 133 | terminalHeight int 134 | trackingActive bool 135 | debug bool 136 | frameCounter uint 137 | logFramesCfg logFramesConfig 138 | } 139 | 140 | func (m *Model) blurTLTrackingInputs() { 141 | for i := range m.tLInputs { 142 | m.tLInputs[i].Blur() 143 | } 144 | m.tLCommentInput.Blur() 145 | } 146 | 147 | func (m Model) Init() tea.Cmd { 148 | return tea.Batch( 149 | hideHelp(time.Minute*1), 150 | fetchTasks(m.db, true), 151 | fetchTLS(m.db, nil), 152 | fetchTasks(m.db, false), 153 | ) 154 | } 155 | 156 | type recordsModel struct { 157 | db *sql.DB 158 | style Style 159 | timeProvider types.TimeProvider 160 | kind recordsKind 161 | dateRange types.DateRange 162 | period string 163 | plain bool 164 | taskStatus types.TaskStatus 165 | report string 166 | quitting bool 167 | busy bool 168 | err error 169 | } 170 | 171 | func (recordsModel) Init() tea.Cmd { 172 | return nil 173 | } 174 | 175 | func infoMsg(msg string) userMsg { 176 | return userMsg{ 177 | value: msg, 178 | kind: userMsgInfo, 179 | framesLeft: userMsgDefaultFrames, 180 | } 181 | } 182 | 183 | func errMsg(msg string) userMsg { 184 | return userMsg{ 185 | value: msg, 186 | kind: userMsgErr, 187 | framesLeft: userMsgDefaultFrames, 188 | } 189 | } 190 | 191 | func errMsgQuick(msg string) userMsg { 192 | return userMsg{ 193 | value: msg, 194 | kind: userMsgErr, 195 | framesLeft: 2, 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /internal/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math" 7 | "time" 8 | 9 | "github.com/dhth/hours/internal/utils" 10 | "github.com/dustin/go-humanize" 11 | ) 12 | 13 | const emptyCommentIndicator = "∅" 14 | 15 | var ErrIncorrectTaskStatusProvided = errors.New("incorrect task status provided") 16 | 17 | type Task struct { 18 | ID int 19 | Summary string 20 | CreatedAt time.Time 21 | UpdatedAt time.Time 22 | TrackingActive bool 23 | SecsSpent int 24 | Active bool 25 | ListTitle string 26 | ListDesc string 27 | } 28 | 29 | type TaskLogEntry struct { 30 | ID int 31 | TaskID int 32 | TaskSummary string 33 | BeginTS time.Time 34 | EndTS time.Time 35 | SecsSpent int 36 | Comment *string 37 | ListTitle string 38 | ListDesc string 39 | } 40 | 41 | type ActiveTaskLogEntry struct { 42 | ID int 43 | TaskID int 44 | TaskSummary string 45 | BeginTS time.Time 46 | Comment *string 47 | } 48 | 49 | type ActiveTaskDetails struct { 50 | TaskID int 51 | TaskSummary string 52 | CurrentLogBeginTS time.Time 53 | CurrentLogComment *string 54 | } 55 | 56 | type TaskReportEntry struct { 57 | TaskID int 58 | TaskSummary string 59 | NumEntries int 60 | SecsSpent int 61 | } 62 | 63 | type TimeProvider interface { 64 | Now() time.Time 65 | } 66 | 67 | type RealTimeProvider struct{} 68 | 69 | func (RealTimeProvider) Now() time.Time { 70 | return time.Now() 71 | } 72 | 73 | type TestTimeProvider struct { 74 | FixedTime time.Time 75 | } 76 | 77 | func (t TestTimeProvider) Now() time.Time { 78 | return t.FixedTime 79 | } 80 | 81 | func (t *Task) UpdateListTitle() { 82 | var trackingIndicator string 83 | if t.TrackingActive { 84 | trackingIndicator = "⏲ " 85 | } 86 | 87 | t.ListTitle = trackingIndicator + t.Summary 88 | } 89 | 90 | func (t *Task) UpdateListDesc(timeProvider TimeProvider) { 91 | var timeSpent string 92 | 93 | if t.SecsSpent != 0 { 94 | timeSpent = "worked on for " + HumanizeDuration(t.SecsSpent) 95 | } else { 96 | timeSpent = "no time spent" 97 | } 98 | lastUpdated := fmt.Sprintf("last updated: %s", humanize.RelTime(t.UpdatedAt, timeProvider.Now(), "ago", "from now")) 99 | 100 | t.ListDesc = fmt.Sprintf("%s %s", utils.RightPadTrim(lastUpdated, 60, true), timeSpent) 101 | } 102 | 103 | func (tl *TaskLogEntry) UpdateListTitle() { 104 | tl.ListTitle = utils.TrimWithMoreLinesIndicator(tl.GetComment(), 60) 105 | } 106 | 107 | func (tl *TaskLogEntry) UpdateListDesc(timeProvider TimeProvider) { 108 | timeSpentStr := HumanizeDuration(tl.SecsSpent) 109 | 110 | var timeStr string 111 | var durationMsg string 112 | 113 | now := timeProvider.Now() 114 | endTSRelative := getTSRelative(tl.EndTS, now) 115 | 116 | switch endTSRelative { 117 | case tsFromToday: 118 | durationMsg = fmt.Sprintf("%s ... %s", tl.BeginTS.Format(timeOnlyFormat), tl.EndTS.Format(timeOnlyFormat)) 119 | case tsFromYesterday: 120 | durationMsg = "Yesterday" 121 | case tsFromThisWeek: 122 | durationMsg = tl.EndTS.Format(dayFormat) 123 | default: 124 | durationMsg = humanize.RelTime(tl.EndTS, now, "ago", "from now") 125 | } 126 | 127 | timeStr = fmt.Sprintf("%s (%s)", 128 | utils.RightPadTrim(durationMsg, 40, true), 129 | timeSpentStr) 130 | 131 | tl.ListDesc = fmt.Sprintf("%s %s", utils.RightPadTrim(tl.TaskSummary, 60, true), timeStr) 132 | } 133 | 134 | func (tl *TaskLogEntry) GetComment() string { 135 | if tl.Comment == nil { 136 | return emptyCommentIndicator 137 | } 138 | 139 | return *tl.Comment 140 | } 141 | 142 | func (t Task) Title() string { 143 | return t.ListTitle 144 | } 145 | 146 | func (t Task) Description() string { 147 | return t.ListDesc 148 | } 149 | 150 | func (t Task) FilterValue() string { 151 | return t.Summary 152 | } 153 | 154 | func (tl TaskLogEntry) Title() string { 155 | return tl.ListTitle 156 | } 157 | 158 | func (tl TaskLogEntry) Description() string { 159 | return tl.ListDesc 160 | } 161 | 162 | func (tl TaskLogEntry) FilterValue() string { 163 | return fmt.Sprintf("%d", tl.ID) 164 | } 165 | 166 | func HumanizeDuration(durationInSecs int) string { 167 | duration := time.Duration(durationInSecs) * time.Second 168 | 169 | if duration.Seconds() < 60 { 170 | return fmt.Sprintf("%ds", int(duration.Seconds())) 171 | } 172 | 173 | if duration.Minutes() < 60 { 174 | return fmt.Sprintf("%dm", int(duration.Minutes())) 175 | } 176 | 177 | modMins := int(math.Mod(duration.Minutes(), 60)) 178 | 179 | if modMins == 0 { 180 | return fmt.Sprintf("%dh", int(duration.Hours())) 181 | } 182 | 183 | return fmt.Sprintf("%dh %dm", int(duration.Hours()), modMins) 184 | } 185 | 186 | type TimeShiftDirection uint8 187 | 188 | const ( 189 | ShiftForward TimeShiftDirection = iota 190 | ShiftBackward 191 | ) 192 | 193 | type TimeShiftDuration uint8 194 | 195 | const ( 196 | ShiftMinute TimeShiftDuration = iota 197 | ShiftFiveMinutes 198 | ShiftHour 199 | ShiftDay 200 | ) 201 | 202 | type TaskStatus uint8 203 | 204 | const ( 205 | TSValueActive = "active" 206 | TSValueInactive = "inactive" 207 | TSValueAny = "any" 208 | ) 209 | 210 | const ( 211 | TaskStatusActive TaskStatus = iota 212 | TaskStatusInactive 213 | TaskStatusAny 214 | ) 215 | 216 | func ParseTaskStatus(value string) (TaskStatus, error) { 217 | switch value { 218 | case TSValueActive: 219 | return TaskStatusActive, nil 220 | case TSValueInactive: 221 | return TaskStatusInactive, nil 222 | case TSValueAny: 223 | return TaskStatusAny, nil 224 | default: 225 | return TaskStatusAny, ErrIncorrectTaskStatusProvided 226 | } 227 | } 228 | 229 | var ValidTaskStatusValues = []string{TSValueActive, TSValueInactive, TSValueAny} 230 | 231 | type DateRange struct { 232 | Start time.Time 233 | End time.Time 234 | NumDays int 235 | } 236 | -------------------------------------------------------------------------------- /internal/ui/cmds.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "time" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | pers "github.com/dhth/hours/internal/persistence" 10 | "github.com/dhth/hours/internal/types" 11 | _ "modernc.org/sqlite" // sqlite driver 12 | ) 13 | 14 | func toggleTracking(db *sql.DB, 15 | taskID int, 16 | beginTs time.Time, 17 | endTs time.Time, 18 | comment *string, 19 | ) tea.Cmd { 20 | return func() tea.Msg { 21 | row := db.QueryRow(` 22 | SELECT id, task_id 23 | FROM task_log 24 | WHERE active=1 25 | ORDER BY begin_ts DESC 26 | LIMIT 1 27 | `) 28 | var isTrackingActive bool 29 | var activeTaskLogID int 30 | var activeTaskID int 31 | 32 | err := row.Scan(&activeTaskLogID, &activeTaskID) 33 | if errors.Is(err, sql.ErrNoRows) { 34 | isTrackingActive = false 35 | } else if err != nil { 36 | return trackingToggledMsg{err: err} 37 | } else { 38 | isTrackingActive = true 39 | } 40 | 41 | switch isTrackingActive { 42 | case false: 43 | _, err = pers.InsertNewTL(db, taskID, beginTs) 44 | if err != nil { 45 | return trackingToggledMsg{err: err} 46 | } 47 | return trackingToggledMsg{taskID: taskID} 48 | 49 | default: 50 | secsSpent := int(endTs.Sub(beginTs).Seconds()) 51 | err := pers.FinishActiveTL(db, activeTaskLogID, activeTaskID, beginTs, endTs, secsSpent, comment) 52 | if err != nil { 53 | return trackingToggledMsg{err: err} 54 | } 55 | return trackingToggledMsg{taskID: taskID, finished: true, secsSpent: secsSpent} 56 | } 57 | } 58 | } 59 | 60 | func quickSwitchActiveIssue(db *sql.DB, taskID int, ts time.Time) tea.Cmd { 61 | return func() tea.Msg { 62 | result, err := pers.QuickSwitchActiveTL(db, taskID, ts) 63 | return activeTLSwitchedMsg{ 64 | lastActiveTaskID: result.LastActiveTaskID, 65 | currentlyActiveTaskID: taskID, 66 | currentlyActiveTLID: result.CurrentlyActiveTLID, 67 | ts: ts, 68 | err: err, 69 | } 70 | } 71 | } 72 | 73 | func updateActiveTL(db *sql.DB, beginTS time.Time, comment *string) tea.Cmd { 74 | return func() tea.Msg { 75 | err := pers.EditActiveTL(db, beginTS, comment) 76 | return activeTLUpdatedMsg{beginTS, comment, err} 77 | } 78 | } 79 | 80 | func insertManualTL(db *sql.DB, taskID int, beginTS time.Time, endTS time.Time, comment *string) tea.Cmd { 81 | return func() tea.Msg { 82 | _, err := pers.InsertManualTL(db, taskID, beginTS, endTS, comment) 83 | return manualTLInsertedMsg{taskID, err} 84 | } 85 | } 86 | 87 | func editSavedTL(db *sql.DB, tlID, taskID int, beginTS time.Time, endTS time.Time, comment *string) tea.Cmd { 88 | return func() tea.Msg { 89 | _, err := pers.EditSavedTL(db, tlID, beginTS, endTS, comment) 90 | return savedTLEditedMsg{tlID, taskID, err} 91 | } 92 | } 93 | 94 | func fetchActiveTask(db *sql.DB) tea.Cmd { 95 | return func() tea.Msg { 96 | activeTaskDetails, err := pers.FetchActiveTaskDetails(db) 97 | if err != nil { 98 | return activeTaskFetchedMsg{err: err} 99 | } 100 | 101 | if activeTaskDetails.TaskID == -1 { 102 | return activeTaskFetchedMsg{noneActive: true} 103 | } 104 | 105 | return activeTaskFetchedMsg{ 106 | activeTask: activeTaskDetails, 107 | } 108 | } 109 | } 110 | 111 | func updateTaskRep(db *sql.DB, t *types.Task) tea.Cmd { 112 | return func() tea.Msg { 113 | err := pers.UpdateTaskData(db, t) 114 | return taskRepUpdatedMsg{ 115 | tsk: t, 116 | err: err, 117 | } 118 | } 119 | } 120 | 121 | func fetchTLS(db *sql.DB, tlIDToFocusOn *int) tea.Cmd { 122 | return func() tea.Msg { 123 | entries, err := pers.FetchTLEntries(db, true, 50) 124 | return tLsFetchedMsg{ 125 | entries: entries, 126 | tlIDToFocusOn: tlIDToFocusOn, 127 | err: err, 128 | } 129 | } 130 | } 131 | 132 | func deleteTL(db *sql.DB, entry *types.TaskLogEntry) tea.Cmd { 133 | return func() tea.Msg { 134 | err := pers.DeleteTL(db, entry) 135 | return tLDeletedMsg{ 136 | entry: entry, 137 | err: err, 138 | } 139 | } 140 | } 141 | 142 | func deleteActiveTL(db *sql.DB) tea.Cmd { 143 | return func() tea.Msg { 144 | err := pers.DeleteActiveTL(db) 145 | return activeTaskLogDeletedMsg{err} 146 | } 147 | } 148 | 149 | func createTask(db *sql.DB, summary string) tea.Cmd { 150 | return func() tea.Msg { 151 | _, err := pers.InsertTask(db, summary) 152 | return taskCreatedMsg{err} 153 | } 154 | } 155 | 156 | func updateTask(db *sql.DB, task *types.Task, summary string) tea.Cmd { 157 | return func() tea.Msg { 158 | err := pers.UpdateTask(db, task.ID, summary) 159 | return taskUpdatedMsg{task, summary, err} 160 | } 161 | } 162 | 163 | func updateTaskActiveStatus(db *sql.DB, task *types.Task, active bool) tea.Cmd { 164 | return func() tea.Msg { 165 | err := pers.UpdateTaskActiveStatus(db, task.ID, active) 166 | return taskActiveStatusUpdatedMsg{task, active, err} 167 | } 168 | } 169 | 170 | func fetchTasks(db *sql.DB, active bool) tea.Cmd { 171 | return func() tea.Msg { 172 | tasks, err := pers.FetchTasks(db, active, 50) 173 | return tasksFetchedMsg{tasks, active, err} 174 | } 175 | } 176 | 177 | func hideHelp(interval time.Duration) tea.Cmd { 178 | return tea.Tick(interval, func(time.Time) tea.Msg { 179 | return hideHelpMsg{} 180 | }) 181 | } 182 | 183 | func getRecordsData( 184 | analyticsType recordsKind, 185 | db *sql.DB, 186 | style Style, 187 | dateRange types.DateRange, 188 | taskStatus types.TaskStatus, 189 | plain bool, 190 | ) tea.Cmd { 191 | return func() tea.Msg { 192 | var data string 193 | var err error 194 | 195 | switch analyticsType { 196 | case reportRecords: 197 | data, err = getReport(db, style, dateRange.Start, dateRange.NumDays, taskStatus, plain) 198 | case reportAggRecords: 199 | data, err = getReportAgg(db, style, dateRange.Start, dateRange.NumDays, taskStatus, plain) 200 | case reportLogs: 201 | data, err = getTaskLog(db, style, dateRange.Start, dateRange.End, taskStatus, 20, plain) 202 | case reportStats: 203 | data, err = getStats(db, style, &dateRange, taskStatus, plain) 204 | } 205 | 206 | return recordsDataFetchedMsg{ 207 | dateRange: dateRange, 208 | report: data, 209 | err: err, 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: pr 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | changes: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: read 11 | outputs: 12 | code: ${{ steps.filter.outputs.code }} 13 | deps: ${{ steps.filter.outputs.deps }} 14 | release: ${{ steps.filter.outputs.release }} 15 | workflows: ${{ steps.filter.outputs.workflows }} 16 | yml: ${{ steps.filter.outputs.yml }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v6 20 | - uses: dorny/paths-filter@v3 21 | id: filter 22 | with: 23 | filters: | 24 | code: 25 | - "cmd/**" 26 | - "internal/**" 27 | - "tests/**" 28 | - "**/*.go" 29 | - "go.*" 30 | - ".golangci.yml" 31 | - "main.go" 32 | - ".github/actions/**" 33 | - ".github/workflows/pr.yml" 34 | deps: 35 | - "go.mod" 36 | - "go.sum" 37 | - ".github/workflows/pr.yml" 38 | release: 39 | - ".goreleaser.yml" 40 | - ".github/workflows/pr.yml" 41 | workflows: 42 | - ".github/workflows/**.yml" 43 | yml: 44 | - "**.yml" 45 | - "**.yaml" 46 | 47 | lint: 48 | needs: changes 49 | if: ${{ needs.changes.outputs.code == 'true' }} 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v6 53 | - name: Set up Go 54 | uses: actions/setup-go@v6 55 | with: 56 | go-version-file: 'go.mod' 57 | - name: golangci-lint 58 | uses: golangci/golangci-lint-action@v9 59 | with: 60 | version: v2.4 61 | 62 | build: 63 | needs: changes 64 | if: ${{ needs.changes.outputs.code == 'true' }} 65 | strategy: 66 | matrix: 67 | os: [ubuntu-latest, macos-latest] 68 | runs-on: ${{ matrix.os }} 69 | steps: 70 | - uses: actions/checkout@v6 71 | - name: Set up Go 72 | uses: actions/setup-go@v6 73 | with: 74 | go-version-file: 'go.mod' 75 | - name: go build 76 | run: go build -v ./... 77 | - name: run hours 78 | run: | 79 | go build . 80 | ./hours --dbpath=/var/tmp/throwaway-1.db report 3d -p 81 | ./hours --dbpath=/var/tmp/throwaway-2.db gen -y 82 | ./hours --dbpath=/var/tmp/throwaway-2.db report 3d -p 83 | ./hours --dbpath=/var/tmp/throwaway-2.db log 3d -p 84 | ./hours --dbpath=/var/tmp/throwaway-2.db stats 3d -p 85 | 86 | test: 87 | needs: changes 88 | if: ${{ needs.changes.outputs.code == 'true' }} 89 | strategy: 90 | matrix: 91 | os: [ubuntu-latest, macos-latest] 92 | runs-on: ${{ matrix.os }} 93 | steps: 94 | - uses: actions/checkout@v6 95 | - name: Set up Go 96 | uses: actions/setup-go@v6 97 | with: 98 | go-version-file: 'go.mod' 99 | - name: go test 100 | run: go test -v ./... 101 | 102 | live-tests: 103 | needs: [changes, test] 104 | if: ${{ needs.changes.outputs.code == 'true' }} 105 | strategy: 106 | matrix: 107 | os: [ubuntu-latest, macos-latest] 108 | runs-on: ${{ matrix.os }} 109 | steps: 110 | - uses: actions/checkout@v6 111 | - name: Set up Go 112 | uses: actions/setup-go@v6 113 | with: 114 | go-version-file: 'go.mod' 115 | - name: go install 116 | run: go install . 117 | - name: Run live tests 118 | run: | 119 | cd tests 120 | ./test.sh 121 | 122 | lint-yaml: 123 | needs: changes 124 | if: ${{ needs.changes.outputs.yml == 'true' }} 125 | runs-on: ubuntu-latest 126 | steps: 127 | - name: Checkout 128 | uses: actions/checkout@v6 129 | - uses: dhth/composite-actions/.github/actions/lint-yaml@main 130 | 131 | lint-workflows: 132 | needs: changes 133 | if: ${{ needs.changes.outputs.workflows == 'true' }} 134 | runs-on: ubuntu-latest 135 | steps: 136 | - name: Checkout 137 | uses: actions/checkout@v6 138 | - uses: dhth/composite-actions/.github/actions/lint-actions@main 139 | 140 | release-check: 141 | needs: changes 142 | if: ${{ needs.changes.outputs.release == 'true' }} 143 | runs-on: ubuntu-latest 144 | steps: 145 | - uses: actions/checkout@v6 146 | - name: Set up Go 147 | uses: actions/setup-go@v6 148 | with: 149 | go-version-file: 'go.mod' 150 | - name: Release check 151 | uses: goreleaser/goreleaser-action@v6 152 | with: 153 | version: 'v2.9.0' 154 | args: check 155 | 156 | back-compat: 157 | needs: changes 158 | if: ${{ needs.changes.outputs.code == 'true' }} 159 | strategy: 160 | matrix: 161 | os: [ubuntu-latest, macos-latest] 162 | runs-on: ${{ matrix.os }} 163 | permissions: 164 | contents: read 165 | steps: 166 | - uses: actions/checkout@v6 167 | with: 168 | ref: main 169 | - name: Set up Go 170 | uses: actions/setup-go@v6 171 | with: 172 | go-version-file: 'go.mod' 173 | - name: build main 174 | run: | 175 | go build -o hours_main 176 | cp hours_main /var/tmp 177 | rm hours_main 178 | - uses: actions/checkout@v6 179 | - name: Set up Go 180 | uses: actions/setup-go@v6 181 | with: 182 | go-version-file: 'go.mod' 183 | - name: build head 184 | run: | 185 | go build -o hours_head 186 | cp hours_head /var/tmp 187 | rm hours_head 188 | - name: Run last version 189 | run: | 190 | /var/tmp/hours_main --dbpath=/var/tmp/throwaway-empty.db report 3d -p 191 | /var/tmp/hours_main --dbpath=/var/tmp/throwaway-with-data.db gen -y 192 | - name: Run current version 193 | run: | 194 | echo "empty" 195 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-empty.db report 3d -p 196 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-empty.db log 3d -p 197 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-empty.db stats 3d -p 198 | echo "with data" 199 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-with-data.db report 3d -p 200 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-with-data.db log 3d -p 201 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-with-data.db stats 3d -p 202 | 203 | vulncheck: 204 | needs: changes 205 | if: ${{ needs.changes.outputs.deps == 'true' }} 206 | runs-on: ubuntu-latest 207 | steps: 208 | - uses: actions/checkout@v6 209 | - name: Set up Go 210 | uses: actions/setup-go@v6 211 | with: 212 | go-version-file: 'go.mod' 213 | - name: install govulncheck 214 | run: go install golang.org/x/vuln/cmd/govulncheck@latest 215 | - name: govulncheck 216 | run: govulncheck ./... 217 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | changes: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | outputs: 14 | code: ${{ steps.filter.outputs.code }} 15 | deps: ${{ steps.filter.outputs.deps }} 16 | release: ${{ steps.filter.outputs.release }} 17 | workflows: ${{ steps.filter.outputs.workflows }} 18 | yml: ${{ steps.filter.outputs.yml }} 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v6 22 | - uses: dorny/paths-filter@v3 23 | id: filter 24 | with: 25 | filters: | 26 | code: 27 | - "cmd/**" 28 | - "internal/**" 29 | - "tests/**" 30 | - "**/*.go" 31 | - "go.*" 32 | - ".golangci.yml" 33 | - "main.go" 34 | - ".github/actions/**" 35 | - ".github/workflows/main.yml" 36 | deps: 37 | - "go.mod" 38 | - "go.sum" 39 | - ".github/workflows/main.yml" 40 | release: 41 | - ".goreleaser.yml" 42 | - ".github/workflows/main.yml" 43 | workflows: 44 | - ".github/workflows/**.yml" 45 | yml: 46 | - "**.yml" 47 | - "**.yaml" 48 | 49 | lint: 50 | needs: changes 51 | if: ${{ needs.changes.outputs.code == 'true' }} 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v6 55 | - name: Set up Go 56 | uses: actions/setup-go@v6 57 | with: 58 | go-version-file: 'go.mod' 59 | - name: golangci-lint 60 | uses: golangci/golangci-lint-action@v9 61 | with: 62 | version: v2.4 63 | 64 | build: 65 | needs: changes 66 | if: ${{ needs.changes.outputs.code == 'true' }} 67 | strategy: 68 | matrix: 69 | os: [ubuntu-latest, macos-latest] 70 | runs-on: ${{ matrix.os }} 71 | steps: 72 | - uses: actions/checkout@v6 73 | - name: Set up Go 74 | uses: actions/setup-go@v6 75 | with: 76 | go-version-file: 'go.mod' 77 | - name: go build 78 | run: go build -v ./... 79 | - name: run hours 80 | run: | 81 | go build . 82 | ./hours --dbpath=/var/tmp/throwaway-1.db report 3d -p 83 | ./hours --dbpath=/var/tmp/throwaway-2.db gen -y 84 | ./hours --dbpath=/var/tmp/throwaway-2.db report 3d -p 85 | ./hours --dbpath=/var/tmp/throwaway-2.db log 3d -p 86 | ./hours --dbpath=/var/tmp/throwaway-2.db stats 3d -p 87 | 88 | test: 89 | needs: changes 90 | if: ${{ needs.changes.outputs.code == 'true' }} 91 | strategy: 92 | matrix: 93 | os: [ubuntu-latest, macos-latest] 94 | runs-on: ${{ matrix.os }} 95 | steps: 96 | - uses: actions/checkout@v6 97 | - name: Set up Go 98 | uses: actions/setup-go@v6 99 | with: 100 | go-version-file: 'go.mod' 101 | - name: go test 102 | run: go test -v ./... 103 | 104 | live-tests: 105 | needs: [changes, test] 106 | if: ${{ needs.changes.outputs.code == 'true' }} 107 | strategy: 108 | matrix: 109 | os: [ubuntu-latest, macos-latest] 110 | runs-on: ${{ matrix.os }} 111 | steps: 112 | - uses: actions/checkout@v6 113 | - name: Set up Go 114 | uses: actions/setup-go@v6 115 | with: 116 | go-version-file: 'go.mod' 117 | - name: go install 118 | run: go install . 119 | - name: Run live tests 120 | run: | 121 | cd tests 122 | ./test.sh 123 | 124 | lint-yaml: 125 | needs: changes 126 | if: ${{ needs.changes.outputs.yml == 'true' }} 127 | runs-on: ubuntu-latest 128 | steps: 129 | - name: Checkout 130 | uses: actions/checkout@v6 131 | - uses: dhth/composite-actions/.github/actions/lint-yaml@main 132 | 133 | lint-workflows: 134 | needs: changes 135 | if: ${{ needs.changes.outputs.workflows == 'true' }} 136 | runs-on: ubuntu-latest 137 | steps: 138 | - name: Checkout 139 | uses: actions/checkout@v6 140 | - uses: dhth/composite-actions/.github/actions/lint-actions@main 141 | 142 | release-check: 143 | needs: changes 144 | if: ${{ needs.changes.outputs.release == 'true' }} 145 | runs-on: ubuntu-latest 146 | steps: 147 | - uses: actions/checkout@v6 148 | - name: Set up Go 149 | uses: actions/setup-go@v6 150 | with: 151 | go-version-file: 'go.mod' 152 | - name: Release check 153 | uses: goreleaser/goreleaser-action@v6 154 | with: 155 | version: 'v2.9.0' 156 | args: check 157 | 158 | back-compat: 159 | needs: changes 160 | if: ${{ needs.changes.outputs.code == 'true' }} 161 | strategy: 162 | matrix: 163 | os: [ubuntu-latest, macos-latest] 164 | runs-on: ${{ matrix.os }} 165 | steps: 166 | - uses: actions/checkout@v6 167 | with: 168 | fetch-depth: 2 169 | - run: git checkout HEAD~1 170 | - name: Set up Go 171 | uses: actions/setup-go@v6 172 | with: 173 | go-version-file: 'go.mod' 174 | - name: build last commit 175 | run: | 176 | go build -o hours_prev 177 | cp hours_prev /var/tmp 178 | rm hours_prev 179 | - run: git checkout main 180 | - name: Set up Go 181 | uses: actions/setup-go@v6 182 | with: 183 | go-version-file: 'go.mod' 184 | - name: build head 185 | run: | 186 | go build -o hours_head 187 | cp hours_head /var/tmp 188 | rm hours_head 189 | - name: Run last version 190 | run: | 191 | /var/tmp/hours_prev --dbpath=/var/tmp/throwaway-empty.db report 3d -p 192 | /var/tmp/hours_prev --dbpath=/var/tmp/throwaway-with-data.db gen -y 193 | - name: Run current version 194 | run: | 195 | echo "empty" 196 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-empty.db report 3d -p 197 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-empty.db log 3d -p 198 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-empty.db stats 3d -p 199 | echo "with data" 200 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-with-data.db report 3d -p 201 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-with-data.db log 3d -p 202 | /var/tmp/hours_head --dbpath=/var/tmp/throwaway-with-data.db stats 3d -p 203 | 204 | vulncheck: 205 | needs: changes 206 | if: ${{ needs.changes.outputs.deps == 'true' }} 207 | runs-on: ubuntu-latest 208 | steps: 209 | - uses: actions/checkout@v6 210 | - name: Set up Go 211 | uses: actions/setup-go@v6 212 | with: 213 | go-version-file: 'go.mod' 214 | - name: install govulncheck 215 | run: go install golang.org/x/vuln/cmd/govulncheck@latest 216 | - name: govulncheck 217 | run: govulncheck ./... 218 | -------------------------------------------------------------------------------- /internal/ui/styles.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "hash/fnv" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/dhth/hours/internal/ui/theme" 8 | "github.com/olekukonko/tablewriter/tw" 9 | ) 10 | 11 | const ( 12 | fallbackTaskColor = "#ada7ff" 13 | ) 14 | 15 | type Style struct { 16 | activeTaskBeginTime lipgloss.Style 17 | activeTaskSummaryMsg lipgloss.Style 18 | empty lipgloss.Style 19 | formContext lipgloss.Style 20 | formFieldName lipgloss.Style 21 | formHelp lipgloss.Style 22 | helpMsg lipgloss.Style 23 | helpPrimary lipgloss.Style 24 | helpSecondary lipgloss.Style 25 | helpTitle lipgloss.Style 26 | initialHelpMsg lipgloss.Style 27 | list lipgloss.Style 28 | listItemDescColor lipgloss.Color 29 | listItemTitleColor lipgloss.Color 30 | recordsBorder lipgloss.Style 31 | recordsDateRange lipgloss.Style 32 | recordsFooter lipgloss.Style 33 | recordsHeader lipgloss.Style 34 | recordsHelp lipgloss.Style 35 | taskEntryHeading lipgloss.Style 36 | taskLogDetails lipgloss.Style 37 | taskLogEntryHeading lipgloss.Style 38 | theme theme.Theme 39 | titleForegroundColor lipgloss.Color 40 | tlFormOkStyle lipgloss.Style 41 | tlFormWarnStyle lipgloss.Style 42 | tlFormErrStyle lipgloss.Style 43 | toolName lipgloss.Style 44 | tracking lipgloss.Style 45 | viewPort lipgloss.Style 46 | } 47 | 48 | func NewStyle(theme theme.Theme) Style { 49 | base := lipgloss.NewStyle(). 50 | PaddingLeft(1). 51 | PaddingRight(1). 52 | Foreground(lipgloss.Color(theme.TitleForeground)) 53 | 54 | baseList := lipgloss.NewStyle().PaddingTop(1).PaddingRight(2).PaddingBottom(1) 55 | 56 | baseHeading := lipgloss.NewStyle(). 57 | Bold(true). 58 | PaddingLeft(1). 59 | PaddingRight(1). 60 | Foreground(lipgloss.Color(theme.TitleForeground)) 61 | 62 | helpMsg := lipgloss.NewStyle(). 63 | PaddingLeft(1). 64 | Bold(true). 65 | Foreground(lipgloss.Color(theme.HelpMsg)) 66 | 67 | tracking := lipgloss.NewStyle(). 68 | PaddingLeft(2). 69 | Bold(true). 70 | Foreground(lipgloss.Color(theme.Tracking)) 71 | 72 | helpTitle := base. 73 | Bold(true). 74 | Background(lipgloss.Color(theme.HelpPrimary)). 75 | Align(lipgloss.Left) 76 | 77 | return Style{ 78 | activeTaskBeginTime: lipgloss.NewStyle().PaddingLeft(1).Foreground(lipgloss.Color(theme.ActiveTaskBeginTime)), 79 | activeTaskSummaryMsg: tracking.PaddingLeft(1).Foreground(lipgloss.Color(theme.ActiveTask)), 80 | empty: lipgloss.NewStyle(), 81 | formContext: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.FormContext)), 82 | formFieldName: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.FormFieldName)), 83 | formHelp: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.FormHelp)), 84 | helpMsg: helpMsg, 85 | helpPrimary: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(theme.HelpPrimary)), 86 | helpSecondary: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.HelpSecondary)), 87 | helpTitle: helpTitle, 88 | initialHelpMsg: helpMsg.Foreground(lipgloss.Color(theme.InitialHelpMsg)), 89 | list: baseList, 90 | listItemDescColor: lipgloss.Color(theme.ListItemDesc), 91 | listItemTitleColor: lipgloss.Color(theme.ListItemTitle), 92 | recordsBorder: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.RecordsBorder)), 93 | recordsDateRange: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.RecordsDateRange)), 94 | recordsFooter: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.RecordsFooter)), 95 | recordsHeader: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.RecordsHeader)), 96 | recordsHelp: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.RecordsHelp)), 97 | taskEntryHeading: baseHeading.Background(lipgloss.Color(theme.TaskEntry)), 98 | taskLogDetails: helpTitle.Background(lipgloss.Color(theme.TaskLogDetailsViewTitle)), 99 | taskLogEntryHeading: baseHeading.Background(lipgloss.Color(theme.TaskLogEntry)), 100 | theme: theme, 101 | titleForegroundColor: lipgloss.Color(theme.TitleForeground), 102 | tlFormOkStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.TaskLogFormInfo)), 103 | tlFormWarnStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.TaskLogFormWarn)), 104 | tlFormErrStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(theme.TaskLogFormError)), 105 | toolName: base.Align(lipgloss.Center).Bold(true).Background(lipgloss.Color(theme.ToolName)), 106 | tracking: tracking, 107 | viewPort: lipgloss.NewStyle().PaddingTop(1).PaddingLeft(2).PaddingRight(2).PaddingBottom(1), 108 | } 109 | } 110 | 111 | func (s *Style) getDynamicStyle(str string) lipgloss.Style { 112 | if len(s.theme.Tasks) == 0 { 113 | return lipgloss.NewStyle(). 114 | Foreground(lipgloss.Color(fallbackTaskColor)) 115 | } 116 | 117 | h := fnv.New32() 118 | _, err := h.Write([]byte(str)) 119 | if err != nil { 120 | return lipgloss.NewStyle(). 121 | Foreground(lipgloss.Color(fallbackTaskColor)) 122 | } 123 | 124 | hash := h.Sum32() 125 | 126 | color := s.theme.Tasks[hash%uint32(len(s.theme.Tasks))] 127 | return lipgloss.NewStyle(). 128 | Foreground(lipgloss.Color(color)) 129 | } 130 | 131 | type reportStyles struct { 132 | headerStyle lipgloss.Style 133 | footerStyle lipgloss.Style 134 | borderStyle lipgloss.Style 135 | plain bool 136 | } 137 | 138 | func (rs reportStyles) symbols(borderStyle tw.BorderStyle) tw.Symbols { 139 | base := tw.NewSymbols(borderStyle) 140 | if rs.plain { 141 | return base 142 | } 143 | 144 | return styledTableSymbols{base: base, style: rs.borderStyle} 145 | } 146 | 147 | type styledTableSymbols struct { 148 | base tw.Symbols 149 | style lipgloss.Style 150 | } 151 | 152 | func (s styledTableSymbols) Name() string { return s.base.Name() } 153 | func (s styledTableSymbols) Center() string { return s.style.Render(s.base.Center()) } 154 | func (s styledTableSymbols) Row() string { return s.style.Render(s.base.Row()) } 155 | func (s styledTableSymbols) Column() string { return s.style.Render(s.base.Column()) } 156 | func (s styledTableSymbols) TopLeft() string { return s.style.Render(s.base.TopLeft()) } 157 | func (s styledTableSymbols) TopMid() string { return s.style.Render(s.base.TopMid()) } 158 | func (s styledTableSymbols) TopRight() string { return s.style.Render(s.base.TopRight()) } 159 | func (s styledTableSymbols) MidLeft() string { return s.style.Render(s.base.MidLeft()) } 160 | func (s styledTableSymbols) MidRight() string { return s.style.Render(s.base.MidRight()) } 161 | func (s styledTableSymbols) BottomLeft() string { return s.style.Render(s.base.BottomLeft()) } 162 | func (s styledTableSymbols) BottomMid() string { return s.style.Render(s.base.BottomMid()) } 163 | func (s styledTableSymbols) BottomRight() string { return s.style.Render(s.base.BottomRight()) } 164 | func (s styledTableSymbols) HeaderLeft() string { return s.style.Render(s.base.HeaderLeft()) } 165 | func (s styledTableSymbols) HeaderMid() string { return s.style.Render(s.base.HeaderMid()) } 166 | func (s styledTableSymbols) HeaderRight() string { return s.style.Render(s.base.HeaderRight()) } 167 | 168 | func (s *Style) getReportStyles(plain bool) reportStyles { 169 | if plain { 170 | return reportStyles{ 171 | headerStyle: s.empty, 172 | footerStyle: s.empty, 173 | borderStyle: s.empty, 174 | plain: true, 175 | } 176 | } 177 | 178 | return reportStyles{ 179 | headerStyle: s.recordsHeader, 180 | footerStyle: s.recordsFooter, 181 | borderStyle: s.recordsBorder, 182 | plain: false, 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /internal/types/date_helpers_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestParseDateRange(t *testing.T) { 13 | start := time.Date(2024, 6, 28, 0, 0, 0, 0, time.Local) 14 | now := time.Date(2024, 6, 30, 0, 0, 0, 0, time.Local) 15 | rangeWithoutEnd := fmt.Sprintf("%s...", start.Format(dateFormat)) 16 | rangeEndingToday := fmt.Sprintf("%s...today", start.Format(dateFormat)) 17 | 18 | testCases := []struct { 19 | name string 20 | input string 21 | expectedStartStr string 22 | expectedEndStr string 23 | expectedNumDays int 24 | expectedErr error 25 | }{ 26 | // success 27 | { 28 | name: "a range of 1 day", 29 | input: "2024/06/10...2024/06/11", 30 | expectedStartStr: "2024/06/10 00:00", 31 | expectedEndStr: "2024/06/11 00:00", 32 | expectedNumDays: 2, 33 | }, 34 | { 35 | name: "a range of 2 days", 36 | input: "2024/06/29...2024/07/01", 37 | expectedStartStr: "2024/06/29 00:00", 38 | expectedEndStr: "2024/07/01 00:00", 39 | expectedNumDays: 3, 40 | }, 41 | { 42 | name: "a range of 1 year", 43 | input: "2024/06/29...2025/06/29", 44 | expectedStartStr: "2024/06/29 00:00", 45 | expectedEndStr: "2025/06/29 00:00", 46 | expectedNumDays: 366, 47 | }, 48 | { 49 | name: "a range without end", 50 | input: rangeWithoutEnd, 51 | expectedStartStr: start.Format(timeFormat), 52 | expectedEndStr: now.Format(timeFormat), 53 | expectedNumDays: 3, 54 | }, 55 | { 56 | name: "a range ending today", 57 | input: rangeEndingToday, 58 | expectedStartStr: start.Format(timeFormat), 59 | expectedEndStr: now.Format(timeFormat), 60 | expectedNumDays: 3, 61 | }, 62 | // failures 63 | { 64 | name: "empty string", 65 | input: "", 66 | expectedErr: errDateRangeIncorrect, 67 | }, 68 | { 69 | name: "only one date", 70 | input: "2024/06/10", 71 | expectedErr: errDateRangeIncorrect, 72 | }, 73 | { 74 | name: "badly formatted start date", 75 | input: "2024/0610...2024/06/10", 76 | expectedErr: errStartDateIncorrect, 77 | }, 78 | { 79 | name: "badly formatted end date", 80 | input: "2024/06/10...2024/0610", 81 | expectedErr: errEndDateIncorrect, 82 | }, 83 | { 84 | name: "a range of 0 days", 85 | input: "2024/06/10...2024/06/10", 86 | expectedErr: errEndDateIsNotAfterStartDate, 87 | }, 88 | { 89 | name: "end date before start date", 90 | input: "2024/06/10...2024/06/08", 91 | expectedErr: errEndDateIsNotAfterStartDate, 92 | }, 93 | } 94 | 95 | for _, tt := range testCases { 96 | t.Run(tt.name, func(t *testing.T) { 97 | got, err := parseDateRange(tt.input, now) 98 | 99 | if tt.expectedErr != nil { 100 | require.ErrorIs(t, err, tt.expectedErr) 101 | return 102 | } 103 | 104 | startStr := got.Start.Format(timeFormat) 105 | endStr := got.End.Format(timeFormat) 106 | 107 | require.NoError(t, err) 108 | assert.Equal(t, tt.expectedStartStr, startStr) 109 | assert.Equal(t, tt.expectedEndStr, endStr) 110 | assert.Equal(t, tt.expectedNumDays, got.NumDays) 111 | }) 112 | } 113 | } 114 | 115 | func TestGetDateRangeFromPeriod(t *testing.T) { 116 | now, err := time.ParseInLocation(timeFormat, "2024/06/20 20:00", time.Local) 117 | if err != nil { 118 | t.Fatalf("error setting up the test: time is not valid: %s", err) 119 | } 120 | 121 | nowME, err := time.ParseInLocation(timeFormat, "2024/05/31 20:00", time.Local) 122 | if err != nil { 123 | t.Fatalf("error setting up the test: time is not valid: %s", err) 124 | } 125 | 126 | nowMB, err := time.ParseInLocation(timeFormat, "2024/06/01 20:00", time.Local) 127 | if err != nil { 128 | t.Fatalf("error setting up the test: time is not valid: %s", err) 129 | } 130 | 131 | maxDaysAllowed := 7 132 | 133 | testCases := []struct { 134 | name string 135 | period string 136 | now time.Time 137 | fullWeek bool 138 | maxDaysAllowed *int 139 | expectedStartStr string 140 | expectedEndStr string 141 | expectedNumDays int 142 | expectedErr error 143 | }{ 144 | // success 145 | { 146 | name: "today", 147 | period: "today", 148 | now: now, 149 | expectedStartStr: "2024/06/20 00:00", 150 | expectedEndStr: "2024/06/21 00:00", 151 | expectedNumDays: 1, 152 | }, 153 | { 154 | name: "'today' at end of month", 155 | period: "today", 156 | now: nowME, 157 | expectedStartStr: "2024/05/31 00:00", 158 | expectedEndStr: "2024/06/01 00:00", 159 | expectedNumDays: 1, 160 | }, 161 | { 162 | name: "'yest' at beginning of month", 163 | period: "yest", 164 | now: nowMB, 165 | expectedStartStr: "2024/05/31 00:00", 166 | expectedEndStr: "2024/06/01 00:00", 167 | expectedNumDays: 1, 168 | }, 169 | { 170 | name: "3d", 171 | period: "3d", 172 | now: now, 173 | expectedStartStr: "2024/06/18 00:00", 174 | expectedEndStr: "2024/06/21 00:00", 175 | expectedNumDays: 3, 176 | }, 177 | { 178 | name: "week", 179 | period: "week", 180 | now: now, 181 | expectedStartStr: "2024/06/17 00:00", 182 | expectedEndStr: "2024/06/21 00:00", 183 | expectedNumDays: 4, 184 | }, 185 | { 186 | name: "full week", 187 | period: "week", 188 | now: now, 189 | fullWeek: true, 190 | expectedStartStr: "2024/06/17 00:00", 191 | expectedEndStr: "2024/06/24 00:00", 192 | expectedNumDays: 7, 193 | }, 194 | { 195 | name: "a date", 196 | period: "2024/06/20", 197 | expectedStartStr: "2024/06/20 00:00", 198 | expectedEndStr: "2024/06/21 00:00", 199 | expectedNumDays: 1, 200 | }, 201 | { 202 | name: "a date range", 203 | period: "2024/06/15...2024/06/20", 204 | maxDaysAllowed: &maxDaysAllowed, 205 | expectedStartStr: "2024/06/15 00:00", 206 | expectedEndStr: "2024/06/21 00:00", 207 | expectedNumDays: 6, 208 | }, 209 | // failures 210 | { 211 | name: "a faulty date", 212 | period: "2024/06-15", 213 | expectedErr: errTimePeriodNotValid, 214 | }, 215 | { 216 | name: "a faulty date range", 217 | period: "2024/06/15...2024", 218 | expectedErr: errTimePeriodNotValid, 219 | }, 220 | { 221 | name: "a date range too large", 222 | period: "2024/06/15...2024/06/22", 223 | maxDaysAllowed: &maxDaysAllowed, 224 | expectedErr: errTimePeriodTooLarge, 225 | }, 226 | } 227 | 228 | for _, tt := range testCases { 229 | t.Run(tt.name, func(t *testing.T) { 230 | got, err := GetDateRangeFromPeriod(tt.period, tt.now, tt.fullWeek, tt.maxDaysAllowed) 231 | 232 | startStr := got.Start.Format(timeFormat) 233 | endStr := got.End.Format(timeFormat) 234 | 235 | if tt.expectedErr == nil { 236 | assert.Equal(t, tt.expectedStartStr, startStr) 237 | assert.Equal(t, tt.expectedEndStr, endStr) 238 | assert.Equal(t, tt.expectedNumDays, got.NumDays) 239 | assert.NoError(t, err) 240 | return 241 | } 242 | assert.ErrorIs(t, err, tt.expectedErr, tt.name) 243 | }) 244 | } 245 | } 246 | 247 | func TestGetTSRelative(t *testing.T) { 248 | reference := time.Date(2024, 6, 29, 12, 0, 0, 0, time.Local) 249 | testCases := []struct { 250 | name string 251 | ts time.Time 252 | reference time.Time 253 | expected tsRelative 254 | }{ 255 | { 256 | name: "ts in the future", 257 | ts: time.Date(2024, 6, 30, 6, 0, 0, 0, time.Local), 258 | reference: reference, 259 | expected: tsFromFuture, 260 | }, 261 | { 262 | name: "ts on the same day as the reference", 263 | ts: time.Date(2024, 6, 29, 6, 0, 0, 0, time.Local), 264 | reference: reference, 265 | expected: tsFromToday, 266 | }, 267 | { 268 | name: "ts from a day before the reference", 269 | ts: time.Date(2024, 6, 28, 23, 59, 0, 0, time.Local), 270 | reference: reference, 271 | expected: tsFromYesterday, 272 | }, 273 | { 274 | name: "ts from the first day of the week", 275 | ts: time.Date(2024, 6, 24, 0, 1, 0, 0, time.Local), 276 | reference: reference, 277 | expected: tsFromThisWeek, 278 | }, 279 | { 280 | name: "ts from before the week", 281 | ts: time.Date(2024, 6, 23, 23, 59, 0, 0, time.Local), 282 | reference: reference, 283 | expected: tsFromBeforeThisWeek, 284 | }, 285 | } 286 | 287 | for _, tt := range testCases { 288 | t.Run(tt.name, func(t *testing.T) { 289 | got := getTSRelative(tt.ts, tt.reference) 290 | assert.Equal(t, tt.expected, got) 291 | }) 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /internal/ui/theme/theme.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | const ( 16 | defaultThemeName = "default" 17 | CustomThemePrefix = "custom:" 18 | ) 19 | 20 | var ( 21 | errThemeFileIsInvalidJSON = errors.New("theme file is not valid JSON") 22 | ErrThemeFileHasInvalidSchema = errors.New("theme file's schema is incorrect") 23 | ErrThemeColorsAreInvalid = errors.New("invalid colors provided") 24 | errCouldntReadCustomThemeFile = errors.New("couldn't read custom theme file") 25 | errCouldntLoadCustomTheme = errors.New("couldn't load custom theme") 26 | errEmptyThemeNameProvided = errors.New("empty theme name provided") 27 | ErrCustomThemeDoesntExist = errors.New("custom theme doesn't exist") 28 | ErrBuiltInThemeDoesntExist = errors.New("built-in theme doesn't exist") 29 | ) 30 | 31 | var hexCodeRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) 32 | 33 | type Theme struct { 34 | ActiveTask string `json:"activeTask,omitempty"` 35 | ActiveTaskBeginTime string `json:"activeTaskBeginTime,omitempty"` 36 | ActiveTasks string `json:"activeTasks,omitempty"` 37 | FormContext string `json:"formContext,omitempty"` 38 | FormFieldName string `json:"formFieldName,omitempty"` 39 | FormHelp string `json:"formHelp,omitempty"` 40 | HelpMsg string `json:"helpMsg,omitempty"` 41 | HelpPrimary string `json:"helpPrimary,omitempty"` 42 | HelpSecondary string `json:"helpSecondary,omitempty"` 43 | InactiveTasks string `json:"inactiveTasks,omitempty"` 44 | InitialHelpMsg string `json:"initialHelpMsg,omitempty"` 45 | ListItemDesc string `json:"listItemDesc,omitempty"` 46 | ListItemTitle string `json:"listItemTitle,omitempty"` 47 | RecordsBorder string `json:"recordsBorder,omitempty"` 48 | RecordsDateRange string `json:"recordsDateRange,omitempty"` 49 | RecordsFooter string `json:"recordsFooter,omitempty"` 50 | RecordsHeader string `json:"recordsHeader,omitempty"` 51 | RecordsHelp string `json:"recordsHelp,omitempty"` 52 | TaskEntry string `json:"taskEntry,omitempty"` 53 | TaskLogDetailsViewTitle string `json:"taskLogDetails,omitempty"` 54 | TaskLogEntry string `json:"taskLogEntry,omitempty"` 55 | TaskLogFormError string `json:"taskLogFormError,omitempty"` 56 | TaskLogFormInfo string `json:"taskLogFormInfo,omitempty"` 57 | TaskLogFormWarn string `json:"taskLogFormWarn,omitempty"` 58 | TaskLogList string `json:"taskLogList,omitempty"` 59 | Tasks []string `json:"tasks,omitempty"` 60 | TitleForeground string `json:"titleForeground,omitempty"` 61 | ToolName string `json:"toolName,omitempty"` 62 | Tracking string `json:"tracking,omitempty"` 63 | } 64 | 65 | func Get(themeName string, themesDir string) (Theme, error) { 66 | var zero Theme 67 | themeName = strings.TrimSpace(themeName) 68 | 69 | if len(themeName) == 0 { 70 | return zero, errEmptyThemeNameProvided 71 | } 72 | 73 | if themeName == defaultThemeName { 74 | return Default(), nil 75 | } 76 | 77 | if customThemeName, ok := strings.CutPrefix(themeName, CustomThemePrefix); ok { 78 | if len(customThemeName) == 0 { 79 | return zero, errEmptyThemeNameProvided 80 | } 81 | 82 | themeFilePath := filepath.Join(themesDir, fmt.Sprintf("%s.json", customThemeName)) 83 | themeBytes, err := os.ReadFile(themeFilePath) 84 | if err != nil { 85 | if errors.Is(err, fs.ErrNotExist) { 86 | return zero, fmt.Errorf("%w: %q", ErrCustomThemeDoesntExist, customThemeName) 87 | } 88 | return zero, fmt.Errorf("%w (%q): %s", errCouldntReadCustomThemeFile, themeFilePath, err.Error()) 89 | } 90 | 91 | theme, err := loadCustom(themeBytes) 92 | if err != nil { 93 | return zero, fmt.Errorf("%w from file %q: %w", errCouldntLoadCustomTheme, themeFilePath, err) 94 | } 95 | 96 | return theme, nil 97 | } 98 | 99 | builtInTheme, err := getBuiltIn(themeName) 100 | if err != nil { 101 | return zero, err 102 | } 103 | 104 | return builtInTheme, nil 105 | } 106 | 107 | func Default() Theme { 108 | return getBuiltInTheme(paletteGruvboxDark()) 109 | } 110 | 111 | func BuiltIn() []string { 112 | return []string{ 113 | themeNameCatppuccinMocha, 114 | themeNameDracula, 115 | themeNameGithubDark, 116 | themeNameGruvboxDark, 117 | themeNameMonokaiClassic, 118 | themeNameNightOwl, 119 | themeNameTokyonight, 120 | themeNameXcodeDark, 121 | } 122 | } 123 | 124 | func loadCustom(themeJSON []byte) (Theme, error) { 125 | thm := Default() 126 | err := json.Unmarshal(themeJSON, &thm) 127 | var syntaxError *json.SyntaxError 128 | 129 | if err != nil { 130 | if errors.As(err, &syntaxError) { 131 | return thm, fmt.Errorf("%w: %w", errThemeFileIsInvalidJSON, err) 132 | } 133 | return thm, fmt.Errorf("%w: %s", ErrThemeFileHasInvalidSchema, err.Error()) 134 | } 135 | 136 | invalidColors := getInvalidColors(thm) 137 | if len(invalidColors) > 0 { 138 | return thm, fmt.Errorf("%w: %q", ErrThemeColorsAreInvalid, invalidColors) 139 | } 140 | 141 | return thm, nil 142 | } 143 | 144 | func getBuiltIn(theme string) (Theme, error) { 145 | var palette builtInThemePalette 146 | switch theme { 147 | case themeNameCatppuccinMocha: 148 | palette = paletteCatppuccinMocha() 149 | case themeNameDracula: 150 | palette = paletteDracula() 151 | case themeNameGithubDark: 152 | palette = paletteGithubDark() 153 | case themeNameGruvboxDark: 154 | palette = paletteGruvboxDark() 155 | case themeNameMonokaiClassic: 156 | palette = paletteMonokaiClassic() 157 | case themeNameNightOwl: 158 | palette = paletteNightOwl() 159 | case themeNameTokyonight: 160 | palette = paletteTokyonight() 161 | case themeNameXcodeDark: 162 | palette = paletteXcodeDark() 163 | default: 164 | return Theme{}, fmt.Errorf("%w: %q", ErrBuiltInThemeDoesntExist, theme) 165 | } 166 | 167 | return getBuiltInTheme(palette), nil 168 | } 169 | 170 | func getInvalidColors(theme Theme) []string { 171 | var invalidColors []string 172 | 173 | if !isValidColor(theme.ActiveTask) { 174 | invalidColors = append(invalidColors, "activeTask") 175 | } 176 | if !isValidColor(theme.ActiveTaskBeginTime) { 177 | invalidColors = append(invalidColors, "activeTaskBeginTime") 178 | } 179 | if !isValidColor(theme.ActiveTasks) { 180 | invalidColors = append(invalidColors, "activeTasks") 181 | } 182 | if !isValidColor(theme.FormContext) { 183 | invalidColors = append(invalidColors, "formContext") 184 | } 185 | if !isValidColor(theme.FormFieldName) { 186 | invalidColors = append(invalidColors, "formFieldName") 187 | } 188 | if !isValidColor(theme.FormHelp) { 189 | invalidColors = append(invalidColors, "formHelp") 190 | } 191 | if !isValidColor(theme.HelpMsg) { 192 | invalidColors = append(invalidColors, "helpMsg") 193 | } 194 | if !isValidColor(theme.HelpPrimary) { 195 | invalidColors = append(invalidColors, "helpPrimary") 196 | } 197 | if !isValidColor(theme.HelpSecondary) { 198 | invalidColors = append(invalidColors, "helpSecondary") 199 | } 200 | if !isValidColor(theme.InactiveTasks) { 201 | invalidColors = append(invalidColors, "inactiveTasks") 202 | } 203 | if !isValidColor(theme.InitialHelpMsg) { 204 | invalidColors = append(invalidColors, "initialHelpMsg") 205 | } 206 | if !isValidColor(theme.ListItemDesc) { 207 | invalidColors = append(invalidColors, "listItemDesc") 208 | } 209 | if !isValidColor(theme.ListItemTitle) { 210 | invalidColors = append(invalidColors, "listItemTitle") 211 | } 212 | if !isValidColor(theme.RecordsBorder) { 213 | invalidColors = append(invalidColors, "recordsBorder") 214 | } 215 | if !isValidColor(theme.RecordsDateRange) { 216 | invalidColors = append(invalidColors, "recordsDateRange") 217 | } 218 | if !isValidColor(theme.RecordsFooter) { 219 | invalidColors = append(invalidColors, "recordsFooter") 220 | } 221 | if !isValidColor(theme.RecordsHeader) { 222 | invalidColors = append(invalidColors, "recordsHeader") 223 | } 224 | if !isValidColor(theme.RecordsHelp) { 225 | invalidColors = append(invalidColors, "recordsHelp") 226 | } 227 | if !isValidColor(theme.TaskLogDetailsViewTitle) { 228 | invalidColors = append(invalidColors, "taskLogDetails") 229 | } 230 | if !isValidColor(theme.TaskEntry) { 231 | invalidColors = append(invalidColors, "taskEntry") 232 | } 233 | if !isValidColor(theme.TaskLogEntry) { 234 | invalidColors = append(invalidColors, "taskLogEntry") 235 | } 236 | if !isValidColor(theme.TaskLogList) { 237 | invalidColors = append(invalidColors, "taskLogList") 238 | } 239 | if !isValidColor(theme.TaskLogFormInfo) { 240 | invalidColors = append(invalidColors, "taskLogFormInfo") 241 | } 242 | if !isValidColor(theme.TaskLogFormWarn) { 243 | invalidColors = append(invalidColors, "taskLogFormWarn") 244 | } 245 | if !isValidColor(theme.TaskLogFormError) { 246 | invalidColors = append(invalidColors, "taskLogFormError") 247 | } 248 | for i, color := range theme.Tasks { 249 | if !isValidColor(color) { 250 | invalidColors = append(invalidColors, fmt.Sprintf("tasks[%d]", i+1)) 251 | } 252 | } 253 | if !isValidColor(theme.TitleForeground) { 254 | invalidColors = append(invalidColors, "titleForeground") 255 | } 256 | if !isValidColor(theme.ToolName) { 257 | invalidColors = append(invalidColors, "toolName") 258 | } 259 | if !isValidColor(theme.Tracking) { 260 | invalidColors = append(invalidColors, "tracking") 261 | } 262 | 263 | return invalidColors 264 | } 265 | 266 | func isValidColor(s string) bool { 267 | if len(s) == 0 { 268 | return false 269 | } 270 | 271 | if strings.HasPrefix(s, "#") { 272 | return hexCodeRegex.MatchString(s) 273 | } 274 | 275 | i, err := strconv.Atoi(s) 276 | if err != nil { 277 | return false 278 | } 279 | 280 | if i < 0 || i > 255 { 281 | return false 282 | } 283 | 284 | return true 285 | } 286 | --------------------------------------------------------------------------------