diff --git a/README.md b/README.md index 32fdd2b..79d2d9c 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,12 @@ ✨ Overview --- -"hours" is a simple CLI app that allows you to track time on tasks you care -about. +"hours" is a CLI app that allows you to track time on tasks you care about. + +"hours" is intended for users who want to do some sort of time tracking for +their projects, but don't want to use an overly complicated app or website to do +so. It has a simple and minimalistic UI; almost everything in it can be achieved +with one or two keypresses. 💾 Install --- @@ -14,3 +18,21 @@ about. ```sh go install github.com/dhth/hours@latest ``` + +⚡️ Usage +--- + +``` +Usage: + hours [flags] [command] + +Flags: + -db-path string + location where hours should create its DB file (default "/Users/dhruvthakur/hours.v1.db") + +Commands: + report + outputs a report of recently added log entries + active + shows the task currently being tracked +``` diff --git a/cmd/root.go b/cmd/root.go index ecc6cdd..b776c70 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,9 +25,22 @@ func Execute() { dbPath := flag.String("db-path", defaultDBPath, "location where hours should create its DB file") flag.Usage = func() { - fmt.Fprintf(os.Stdout, "Track time on your tasks.\n\nFlags:\n") + fmt.Fprintf(os.Stdout, `Track time on your tasks via a simple TUI. + +Usage: + hours [flags] [command] + +Flags: +`) flag.CommandLine.SetOutput(os.Stdout) flag.PrintDefaults() + fmt.Fprintf(os.Stdout, ` +Commands: + report + outputs a report of recently added log entries + active + shows the task currently being tracked +`) } flag.Parse() @@ -43,6 +56,16 @@ func Execute() { os.Exit(1) } - ui.RenderUI(db) + args := os.Args[1:] + out := os.Stdout + if len(args) > 0 { + if args[0] == "report" { + ui.RenderTaskLogReport(db, out) + } else if args[0] == "active" { + ui.ShowActiveTask(db, out) + } + } else { + ui.RenderUI(db) + } } diff --git a/go.mod b/go.mod index c440289..ec35d58 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect diff --git a/go.sum b/go.sum index 2ace262..c49626e 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -47,6 +48,8 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= diff --git a/internal/ui/active.go b/internal/ui/active.go new file mode 100644 index 0000000..e3b4047 --- /dev/null +++ b/internal/ui/active.go @@ -0,0 +1,23 @@ +package ui + +import ( + "database/sql" + "fmt" + "io" + "os" +) + +func ShowActiveTask(db *sql.DB, writer io.Writer) { + activeTaskDetails, err := fetchActiveTaskFromDB(db) + + if err != nil { + fmt.Fprintf(os.Stdout, "Something went wrong:\n%s", err) + os.Exit(1) + } + + if activeTaskDetails.taskId == -1 { + return + } + + fmt.Fprintf(writer, "%s", activeTaskDetails.taskSummary) +} diff --git a/internal/ui/cmds.go b/internal/ui/cmds.go index 36c2943..ce8086c 100644 --- a/internal/ui/cmds.go +++ b/internal/ui/cmds.go @@ -65,17 +65,20 @@ func insertManualEntry(db *sql.DB, taskId int, beginTS time.Time, endTS time.Tim func fetchActiveTask(db *sql.DB) tea.Cmd { return func() tea.Msg { - id, beginTs, err := fetchActiveTaskFromDB(db) + activeTaskDetails, err := fetchActiveTaskFromDB(db) if err != nil { return activeTaskFetchedMsg{err: err} } - if id == -1 { + if activeTaskDetails.taskId == -1 { return activeTaskFetchedMsg{noneActive: true} } - return activeTaskFetchedMsg{activeTaskId: id, beginTs: beginTs} + return activeTaskFetchedMsg{ + activeTaskId: activeTaskDetails.taskId, + beginTs: activeTaskDetails.lastLogEntryBeginTS, + } } } @@ -91,7 +94,7 @@ func updateTaskRep(db *sql.DB, t *task) tea.Cmd { func fetchTaskLogEntries(db *sql.DB) tea.Cmd { return func() tea.Msg { - entries, err := fetchTLEntriesFromDB(db) + entries, err := fetchTLEntriesFromDB(db, 50) return taskLogEntriesFetchedMsg{ entries: entries, err: err, diff --git a/internal/ui/help.go b/internal/ui/help.go index 4b4f49e..ceebba2 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -25,10 +25,6 @@ var ( helpSectionStyle.Render(` (scroll line by line with j/k/arrow keys or by half a page with /) - "hours" is intended for those who want to do some sort of time tracking for their projects, - but don't want to use an overly complicated app or website to do so. "hours" has a simple - and minimalistic UI; almost everything in it can be achieved with one or two keypresses. - "hours" has 5 panes: - Tasks List View Shows your tasks - Task Management View Allows you to create/update tasks diff --git a/internal/ui/queries.go b/internal/ui/queries.go index 660eabf..8a91f1a 100644 --- a/internal/ui/queries.go +++ b/internal/ui/queries.go @@ -122,26 +122,27 @@ WHERE id = ?; return nil } -func fetchActiveTaskFromDB(db *sql.DB) (int, time.Time, error) { +func fetchActiveTaskFromDB(db *sql.DB) (activeTaskDetails, error) { row := db.QueryRow(` -SELECT task_id, begin_ts -FROM task_log -WHERE active=true; +SELECT t.id, t.summary, tl.begin_ts +FROM task_log tl left join task t on tl.task_id = t.id +WHERE tl.active=true; `) - var activeTaskId int - var beginTs time.Time + var activeTaskDetails activeTaskDetails err := row.Scan( - &activeTaskId, - &beginTs, + &activeTaskDetails.taskId, + &activeTaskDetails.taskSummary, + &activeTaskDetails.lastLogEntryBeginTS, ) if errors.Is(err, sql.ErrNoRows) { - return -1, beginTs, nil + activeTaskDetails.taskId = -1 + return activeTaskDetails, nil } else if err != nil { - return -1, beginTs, err + return activeTaskDetails, err } - return activeTaskId, beginTs, nil + return activeTaskDetails, nil } func insertTaskInDB(db *sql.DB, summary string) error { @@ -255,7 +256,7 @@ LIMIT 100; return tasks, nil } -func fetchTLEntriesFromDB(db *sql.DB) ([]taskLogEntry, error) { +func fetchTLEntriesFromDB(db *sql.DB, limit int) ([]taskLogEntry, error) { var logEntries []taskLogEntry @@ -263,8 +264,8 @@ func fetchTLEntriesFromDB(db *sql.DB) ([]taskLogEntry, error) { SELECT tl.id, tl.task_id, t.summary, tl.begin_ts, tl.end_ts, tl.comment FROM task_log tl left join task t on tl.task_id=t.id WHERE tl.active=false -ORDER by tl.begin_ts DESC LIMIT 30; - `) +ORDER by tl.begin_ts DESC LIMIT ?; + `, limit) if err != nil { return nil, err } diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 2a5bf5e..a9e0f68 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -32,5 +32,7 @@ func (tl *taskLogEntry) updateDesc() { secsSpent := int(tl.endTS.Sub(tl.beginTS).Seconds()) timeSpentStr := humanizeDuration(secsSpent) - tl.desc = fmt.Sprintf("%s (spent %s)", RightPadTrim(humanize.Time(tl.beginTS), 60), timeSpentStr) + timeStr := fmt.Sprintf("%s (spent %s)", RightPadTrim(humanize.Time(tl.beginTS), 30), timeSpentStr) + + tl.desc = fmt.Sprintf("%s %s", RightPadTrim("["+tl.taskSummary+"]", 60), timeStr) } diff --git a/internal/ui/report.go b/internal/ui/report.go new file mode 100644 index 0000000..81b1a57 --- /dev/null +++ b/internal/ui/report.go @@ -0,0 +1,43 @@ +package ui + +import ( + "database/sql" + "fmt" + "io" + "os" + + "github.com/olekukonko/tablewriter" +) + +func RenderTaskLogReport(db *sql.DB, writer io.Writer) { + taskLogEntries, err := fetchTLEntriesFromDB(db, 20) + if err != nil { + fmt.Fprintf(writer, "Something went wrong generating the report:\n%s", err) + os.Exit(1) + } + + if len(taskLogEntries) == 0 { + return + } + + data := make([][]string, len(taskLogEntries)) + var secsSpent int + var timeSpentStr string + + for i, entry := range taskLogEntries { + secsSpent = int(entry.endTS.Sub(entry.beginTS).Seconds()) + timeSpentStr = humanizeDuration(secsSpent) + data[i] = []string{ + fmt.Sprintf("%d", entry.id), + entry.taskSummary, + entry.beginTS.Format(timeFormat), + timeSpentStr, + } + } + + table := tablewriter.NewWriter(writer) + table.SetHeader([]string{"ID", "Task", "Begin TS", "Time Spent"}) + + table.AppendBulk(data) + table.Render() +} diff --git a/internal/ui/types.go b/internal/ui/types.go index c5b8e9b..65ef750 100644 --- a/internal/ui/types.go +++ b/internal/ui/types.go @@ -27,6 +27,12 @@ type taskLogEntry struct { desc string } +type activeTaskDetails struct { + taskId int + taskSummary string + lastLogEntryBeginTS time.Time +} + func (t task) Title() string { return t.title } diff --git a/internal/ui/update.go b/internal/ui/update.go index 790dcf2..e10fcb1 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -216,7 +216,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if fs == list.Filtering || fs == list.FilterApplied { m.taskLogList.ResetFilter() } else { - return m, tea.Quit + m.activeView = activeTaskListView + } + case inactiveTaskListView: + fs := m.inactiveTasksList.FilterState() + if fs == list.Filtering || fs == list.FilterApplied { + m.inactiveTasksList.ResetFilter() + } else { + m.activeView = activeTaskListView } case helpView: m.activeView = activeTaskListView @@ -242,6 +249,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case taskLogView: cmds = append(cmds, fetchTaskLogEntries(m.db)) m.taskLogList.ResetSelected() + case inactiveTaskListView: + cmds = append(cmds, fetchTasks(m.db, false)) + m.inactiveTasksList.ResetSelected() } case "ctrl+t": if m.activeView == activeTaskListView {