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
2 changes: 2 additions & 0 deletions cmd/entries/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ func DeleteCmd() *cobra.Command {
os.Exit(1)
}

db.SaveLastAction(storage.UndoAction{Type: storage.ActionDelete, ProjectName: selectedEntry.ProjectName, Entry: selectedEntry})

fmt.Println()
ui.PrintSuccess(ui.EmojiSuccess, "Entry deleted successfully")
ui.NewlineBelow()
Expand Down
2 changes: 2 additions & 0 deletions cmd/entries/manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ func ManualCmd() *cobra.Command {
os.Exit(1)
}

db.SaveLastAction(storage.UndoAction{Type: storage.ActionManual, EntryID: entry.ID, ProjectName: entry.ProjectName})

duration := entry.Duration()
fmt.Println()
ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created manual entry for %s", ui.Bold(entry.ProjectName)))
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Track time effortlessly with automatic project detection and simple commands.`,
cmd.Flags().BoolP("version", "v", false, "version for tmpo")

// Utilities
cmd.AddCommand(utilities.UndoCmd())
cmd.AddCommand(utilities.VersionCmd())

// Tracking
Expand Down
2 changes: 2 additions & 0 deletions cmd/tracking/pause.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ func PauseCmd() *cobra.Command {
os.Exit(1)
}

db.SaveLastAction(storage.UndoAction{Type: storage.ActionPause, EntryID: running.ID, ProjectName: running.ProjectName})

duration := time.Since(running.StartTime)

ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Paused tracking %s", ui.Bold(running.ProjectName)))
Expand Down
2 changes: 2 additions & 0 deletions cmd/tracking/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ func ResumeCmd() *cobra.Command {
os.Exit(1)
}

db.SaveLastAction(storage.UndoAction{Type: storage.ActionResume, EntryID: entry.ID, ProjectName: entry.ProjectName})

ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Resumed tracking time for %s", ui.Bold(entry.ProjectName)))

if entry.Description != "" {
Expand Down
2 changes: 2 additions & 0 deletions cmd/tracking/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ func StartCmd() *cobra.Command {
os.Exit(1)
}

db.SaveLastAction(storage.UndoAction{Type: storage.ActionStart, EntryID: entry.ID, ProjectName: entry.ProjectName})

ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Started tracking time for %s", ui.Bold(entry.ProjectName)))

// communicate config source to user
Expand Down
2 changes: 2 additions & 0 deletions cmd/tracking/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ func StopCmd() *cobra.Command {
os.Exit(1)
}

db.SaveLastAction(storage.UndoAction{Type: storage.ActionStop, EntryID: running.ID, ProjectName: running.ProjectName})

duration := time.Since(running.StartTime)

ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Stopped tracking %s", ui.Bold(running.ProjectName)))
Expand Down
110 changes: 110 additions & 0 deletions cmd/utilities/undo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package utilities

import (
"fmt"
"os"

"github.com/DylanDevelops/tmpo/internal/storage"
"github.com/DylanDevelops/tmpo/internal/ui"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
)

var actionDescriptions = map[storage.ActionType]string{
storage.ActionStop: "Stopped tracking",
storage.ActionPause: "Paused tracking",
storage.ActionStart: "Started tracking",
storage.ActionResume: "Resumed tracking",
storage.ActionManual: "Created manual entry for",
storage.ActionDelete: "Deleted entry for",
}

func UndoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "undo",
Short: "Undo the previous action",
Long: `Undo the previous action in case of a mistake or in need of a rollback.`,
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()

action, err := db.GetLastAction()
if err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err))
os.Exit(1)
}

if action == nil {
ui.PrintWarning(ui.EmojiWarning, "Nothing to undo.")
ui.NewlineBelow()
return
}

ui.PrintInfo(0, ui.EmojiUndo+" Last action", undoActionDescription(action))
fmt.Println()

confirmPrompt := promptui.Prompt{
Label: "Undo this action? [y/N]",
IsConfirm: true,
}
if _, err := confirmPrompt.Run(); err != nil {
ui.PrintWarning(ui.EmojiWarning, "Undo cancelled.")
ui.NewlineBelow()
return
}

if err := applyUndo(db, action); err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("undo failed: %v", err))
ui.NewlineBelow()
os.Exit(1)
}

// not fatal if fails
db.ClearLastAction()

ui.PrintSuccess(ui.EmojiUndo, "Undo successful.")
ui.NewlineBelow()
},
}

return cmd
}

func undoActionDescription(action *storage.UndoAction) string {
if prefix, ok := actionDescriptions[action.Type]; ok {
return fmt.Sprintf("%s %s", prefix, ui.Bold(action.ProjectName))
}
return fmt.Sprintf("Unknown action: %s", action.Type)
}

func applyUndo(db *storage.Database, action *storage.UndoAction) error {
switch action.Type {
case storage.ActionStop, storage.ActionPause:
running, err := db.GetRunningEntry()
if err != nil {
return fmt.Errorf("checking for running entry: %w", err)
}
if running != nil {
return fmt.Errorf("a timer is already running for %s — stop it first with 'tmpo stop'", running.ProjectName)
}
return db.UncompleteEntry(action.EntryID)

case storage.ActionStart, storage.ActionResume, storage.ActionManual:
return db.DeleteTimeEntry(action.EntryID)

case storage.ActionDelete:
if action.Entry == nil {
return fmt.Errorf("no entry snapshot available to restore")
}
return db.RestoreDeletedEntry(action.Entry)

default:
return fmt.Errorf("unknown action type: %s", action.Type)
}
}
101 changes: 101 additions & 0 deletions cmd/utilities/undo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package utilities

import (
"testing"

"github.com/DylanDevelops/tmpo/internal/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func setupUndoTestDB(t *testing.T) *storage.Database {
t.Helper()
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
t.Setenv("USERPROFILE", tmpHome)
t.Setenv("TMPO_DEV", "1")
db, err := storage.Initialize()
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
return db
}

func TestUndoActionDescription(t *testing.T) {
tests := []struct {
actionType storage.ActionType
contains string
}{
{storage.ActionStop, "Stopped tracking"},
{storage.ActionPause, "Paused tracking"},
{storage.ActionStart, "Started tracking"},
{storage.ActionResume, "Resumed tracking"},
{storage.ActionManual, "Created manual entry for"},
{storage.ActionDelete, "Deleted entry for"},
}

for _, tt := range tests {
t.Run(string(tt.actionType), func(t *testing.T) {
action := &storage.UndoAction{Type: tt.actionType, ProjectName: "proj"}
desc := undoActionDescription(action)
assert.Contains(t, desc, tt.contains)
assert.Contains(t, desc, "proj")
})
}
}

func TestUndoActionDescription_Unknown(t *testing.T) {
action := &storage.UndoAction{Type: "something_new", ProjectName: "proj"}
desc := undoActionDescription(action)
assert.Contains(t, desc, "Unknown action")
assert.Contains(t, desc, "something_new")
}

func TestApplyUndo_Stop_ErrorWhenTimerAlreadyRunning(t *testing.T) {
db := setupUndoTestDB(t)

stopped, err := db.CreateEntry("proj", "", nil, nil)
require.NoError(t, err)
require.NoError(t, db.StopEntry(stopped.ID))

_, err = db.CreateEntry("other", "", nil, nil)
require.NoError(t, err)

action := &storage.UndoAction{Type: storage.ActionStop, EntryID: stopped.ID, ProjectName: "proj"}
err = applyUndo(db, action)
assert.Error(t, err)
assert.Contains(t, err.Error(), "timer is already running")
}

func TestApplyUndo_Pause_ErrorWhenTimerAlreadyRunning(t *testing.T) {
db := setupUndoTestDB(t)

stopped, err := db.CreateEntry("proj", "", nil, nil)
require.NoError(t, err)
require.NoError(t, db.StopEntry(stopped.ID))

_, err = db.CreateEntry("other", "", nil, nil)
require.NoError(t, err)

action := &storage.UndoAction{Type: storage.ActionPause, EntryID: stopped.ID, ProjectName: "proj"}
err = applyUndo(db, action)
assert.Error(t, err)
assert.Contains(t, err.Error(), "timer is already running")
}

func TestApplyUndo_Delete_ErrorWhenNoSnapshot(t *testing.T) {
db := setupUndoTestDB(t)

action := &storage.UndoAction{Type: storage.ActionDelete, ProjectName: "proj", Entry: nil}
err := applyUndo(db, action)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no entry snapshot")
}

func TestApplyUndo_UnknownType_ReturnsError(t *testing.T) {
db := setupUndoTestDB(t)

action := &storage.UndoAction{Type: "bogus", ProjectName: "proj"}
err := applyUndo(db, action)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown action type")
}
115 changes: 115 additions & 0 deletions internal/storage/undo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package storage

import (
"database/sql"
"encoding/json"
"fmt"
"time"
)

type ActionType string

const (
ActionStop ActionType = "stop"
ActionStart ActionType = "start"
ActionPause ActionType = "pause"
ActionResume ActionType = "resume"
ActionDelete ActionType = "delete"
ActionManual ActionType = "manual"
)

const lastActionKey = "last_action"

type UndoAction struct {
Type ActionType `json:"type"`
EntryID int64 `json:"entry_id,omitempty"`
ProjectName string `json:"project_name,omitempty"`
Entry *TimeEntry `json:"entry,omitempty"`
}

func (d *Database) SaveLastAction(action UndoAction) error {
data, err := json.Marshal(action)
if err != nil {
return fmt.Errorf("failed to serialize action: %w", err)
}
_, err = d.db.Exec(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
lastActionKey,
string(data),
time.Now().UTC(),
)
if err != nil {
return fmt.Errorf("failed to save last action: %w", err)
}
return nil
}

func (d *Database) GetLastAction() (*UndoAction, error) {
var value string
err := d.db.QueryRow("SELECT value FROM settings WHERE key = ?", lastActionKey).Scan(&value)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get last action: %w", err)
}
var action UndoAction
if err := json.Unmarshal([]byte(value), &action); err != nil {
return nil, fmt.Errorf("failed to parse last action: %w", err)
}
return &action, nil
}

func (d *Database) ClearLastAction() error {
_, err := d.db.Exec("DELETE FROM settings WHERE key = ?", lastActionKey)
if err != nil {
return fmt.Errorf("failed to clear last action: %w", err)
}
return nil
}

// UncompleteEntry clears the end_time of an entry, resuming it as a running timer.
func (d *Database) UncompleteEntry(id int64) error {
result, err := d.db.Exec("UPDATE time_entries SET end_time = NULL WHERE id = ?", id)
if err != nil {
return fmt.Errorf("failed to uncomplete entry: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to uncomplete entry: %w", err)
}
if rows == 0 {
return fmt.Errorf("entry %d not found", id)
}
return nil
}

// RestoreDeletedEntry re-inserts a previously deleted entry preserving its original ID.
func (d *Database) RestoreDeletedEntry(entry *TimeEntry) error {
var endTime sql.NullTime
if entry.EndTime != nil {
endTime = sql.NullTime{Time: entry.EndTime.UTC(), Valid: true}
}
var rate sql.NullFloat64
if entry.HourlyRate != nil {
rate = sql.NullFloat64{Float64: *entry.HourlyRate, Valid: true}
}
var milestone sql.NullString
if entry.MilestoneName != nil {
milestone = sql.NullString{String: *entry.MilestoneName, Valid: true}
}
_, err := d.db.Exec(
"INSERT INTO time_entries (id, project_name, start_time, end_time, description, hourly_rate, milestone_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
entry.ID,
entry.ProjectName,
entry.StartTime.UTC(),
endTime,
entry.Description,
rate,
milestone,
)
if err != nil {
return fmt.Errorf("failed to restore entry: %w", err)
}
return nil
}
Loading
Loading