Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions cmd/pause.go
Original file line number Diff line number Diff line change
@@ -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)
}
71 changes: 71 additions & 0 deletions cmd/resume.go
Original file line number Diff line number Diff line change
@@ -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)
}
52 changes: 52 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions internal/storage/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
51 changes: 51 additions & 0 deletions internal/storage/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down