From 627460ace26a0e02a5a739a56d6bf598b3d01102 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Thu, 18 Dec 2025 23:46:46 -0700 Subject: [PATCH] Add pause and resume commands for time tracking Introduces `pause` and `resume` commands to allow users to pause the current time tracking session and resume it later with the same project and description. Updates documentation to explain the new workflow and use cases. Adds `GetLastStoppedEntry` to the database layer and corresponding tests to support resuming the most recent stopped session. --- cmd/pause.go | 58 ++++++++++++++++++++++++++++++ cmd/resume.go | 71 +++++++++++++++++++++++++++++++++++++ docs/usage.md | 52 +++++++++++++++++++++++++++ internal/storage/db.go | 42 ++++++++++++++++++++++ internal/storage/db_test.go | 51 ++++++++++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 cmd/pause.go create mode 100644 cmd/resume.go diff --git a/cmd/pause.go b/cmd/pause.go new file mode 100644 index 0000000..d5caabf --- /dev/null +++ b/cmd/pause.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +var pauseCmd = &cobra.Command{ + Use: "pause", + Short: "Pause time tracking", + Long: `Pause the currently running time tracking session. Use 'tmpo resume' to continue tracking.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running == nil { + ui.PrintWarning(ui.EmojiWarning, "No active time tracking session to pause.") + ui.NewlineBelow() + os.Exit(0) + } + + err = db.StopEntry(running.ID) + if(err != nil) { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + duration := time.Since(running.StartTime) + + ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Paused tracking %s", ui.Bold(running.ProjectName))) + ui.PrintInfo(4, ui.Bold("Session Duration"), ui.FormatDuration(duration)) + ui.PrintMuted(4, "Use 'tmpo resume' to continue tracking") + + ui.NewlineBelow() + }, +} + +func init() { + rootCmd.AddCommand(pauseCmd) +} diff --git a/cmd/resume.go b/cmd/resume.go new file mode 100644 index 0000000..903b365 --- /dev/null +++ b/cmd/resume.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +var resumeCmd = &cobra.Command{ + Use: "resume", + Short: "Resume time tracking", + Long: `Resume time tracking by starting a new session with the same project and description as the last paused session.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) + ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") + ui.NewlineBelow() + os.Exit(1) + } + + lastStopped, err := db.GetLastStoppedEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if lastStopped == nil { + ui.PrintError(ui.EmojiError, "No previous session found to resume.") + ui.PrintMuted(0, "Use 'tmpo start' to begin a new session.") + ui.NewlineBelow() + os.Exit(1) + } + + entry, err := db.CreateEntry(lastStopped.ProjectName, lastStopped.Description, lastStopped.HourlyRate) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Resumed tracking time for %s", ui.Bold(entry.ProjectName))) + + if entry.Description != "" { + ui.PrintInfo(4, "Description", entry.Description) + } + + ui.NewlineBelow() + }, +} + +func init() { + rootCmd.AddCommand(resumeCmd) +} diff --git a/docs/usage.md b/docs/usage.md index 0f0e1da..a67eac2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -27,6 +27,41 @@ Stop the currently running time entry. tmpo stop ``` +### `tmpo pause` + +Pause the currently running time entry. This is useful for taking quick breaks without losing context. The paused session can be resumed with `tmpo resume`. + +```bash +tmpo pause +# Output: +# [tmpo] Paused tracking my-project +# Session Duration: 45m 23s +# Use 'tmpo resume' to continue tracking +``` + +**How it works:** + +- Stops the current time entry (records end time) +- Use `tmpo resume` to start a new entry with the same project and description +- Each pause creates a separate time entry, giving you a detailed audit trail + +### `tmpo resume` + +Resume time tracking by starting a new session with the same project and description as the last paused (or stopped) session. + +```bash +tmpo resume +# Output: +# [tmpo] Resumed tracking time for my-project +# Description: Implementing feature +``` + +**Use cases:** + +- Continue work after a break +- Resume after accidentally stopping the timer +- Quickly restart the same task + ### `tmpo status` View the current tracking session with elapsed time. @@ -102,6 +137,7 @@ tmpo init --accept-defaults # Creates .tmporc with defaults, no prompts ``` This creates a `.tmporc` file with: + - Project name from Git repo or directory name - Hourly rate of 0 (disabled) - Empty description @@ -234,6 +270,22 @@ my-project,Implementing feature,2024-01-15 14:30:00,2024-01-15 16:45:00,2.25 ## Tips and Workflows +### Taking Breaks with Pause/Resume + +Use pause and resume for quick breaks without losing context: + +```bash +tmpo start "Implementing authentication" +# ... work for a while ... +tmpo pause # Take a lunch break +# ... break time ... +tmpo resume # Continue same task +# ... more work ... +tmpo stop # Done for the day +``` + +This creates separate entries for each work session, making it easy to see your actual working time versus break time when reviewing your log. + ### Quick Daily Review ```bash diff --git a/internal/storage/db.go b/internal/storage/db.go index 046d100..7d00643 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -188,6 +188,48 @@ func (d *Database) GetRunningEntry() (*TimeEntry, error) { return &entry, nil } +// GetLastStoppedEntry retrieves the most recently stopped time entry (i.e. has a non-NULL +// end_time) from the time_entries table. The query orders by start_time descending and +// returns at most one row. +// +// If there is no stopped entry, GetLastStoppedEntry returns (nil, nil). If the database +// query or scan fails, it returns a non-nil error describing the failure. +// +// The function scans id, project_name, start_time, end_time, description and hourly_rate into a +// TimeEntry. Since this query only returns stopped entries, the EndTime field will always be non-nil. +// The HourlyRate field is set only if the scanned hourly_rate is non-NULL (sql.NullFloat64.Valid). +func (d *Database) GetLastStoppedEntry() (*TimeEntry, error) { + var entry TimeEntry + var endTime sql.NullTime + var hourlyRate sql.NullFloat64 + + err := d.db.QueryRow(` + SELECT id, project_name, start_time, end_time, description, hourly_rate + FROM time_entries + WHERE end_time IS NOT NULL + ORDER BY start_time DESC + LIMIT 1 + `).Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description, &hourlyRate) + + if err == sql.ErrNoRows { + return nil, nil + } + + if err != nil { + return nil, fmt.Errorf("failed to get last stopped entry: %w", err) + } + + if endTime.Valid { + entry.EndTime = &endTime.Time + } + + if hourlyRate.Valid { + entry.HourlyRate = &hourlyRate.Float64 + } + + return &entry, nil +} + // StopEntry sets the end_time of the time entry identified by id to the current time. // It updates the corresponding row in the time_entries table using time.Now(). // If the update fails (for example if the row does not exist or the database returns an error), diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index c5995d6..25fb944 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -130,6 +130,57 @@ func TestGetRunningEntry(t *testing.T) { assert.Nil(t, running) } +func TestGetLastStoppedEntry(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // No stopped entries initially + stopped, err := db.GetLastStoppedEntry() + assert.NoError(t, err) + assert.Nil(t, stopped) + + // Create and stop first entry + entry1, err := db.CreateEntry("project-1", "first task", nil) + assert.NoError(t, err) + time.Sleep(10 * time.Millisecond) + err = db.StopEntry(entry1.ID) + assert.NoError(t, err) + + // Should return the stopped entry + stopped, err = db.GetLastStoppedEntry() + assert.NoError(t, err) + assert.NotNil(t, stopped) + assert.Equal(t, entry1.ID, stopped.ID) + assert.NotNil(t, stopped.EndTime) + + // Create and stop second entry (more recent) + time.Sleep(10 * time.Millisecond) + entry2, err := db.CreateEntry("project-2", "second task", nil) + assert.NoError(t, err) + time.Sleep(10 * time.Millisecond) + err = db.StopEntry(entry2.ID) + assert.NoError(t, err) + + // Should return the most recent stopped entry + stopped, err = db.GetLastStoppedEntry() + assert.NoError(t, err) + assert.NotNil(t, stopped) + assert.Equal(t, entry2.ID, stopped.ID) + assert.Equal(t, "project-2", stopped.ProjectName) + assert.Equal(t, "second task", stopped.Description) + + // Create a running entry + entry3, err := db.CreateEntry("project-3", "running task", nil) + assert.NoError(t, err) + + // Should still return entry2, not the running entry3 + stopped, err = db.GetLastStoppedEntry() + assert.NoError(t, err) + assert.NotNil(t, stopped) + assert.Equal(t, entry2.ID, stopped.ID) + assert.NotEqual(t, entry3.ID, stopped.ID) +} + func TestStopEntry(t *testing.T) { db := setupTestDB(t) defer db.Close()