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()