Skip to content

Commit 38d52af

Browse files
feat: tmpo undo to revert a previous action (#119)
2 parents b413cad + 5bc9ef1 commit 38d52af

12 files changed

Lines changed: 502 additions & 0 deletions

File tree

cmd/entries/delete.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ func DeleteCmd() *cobra.Command {
161161
os.Exit(1)
162162
}
163163

164+
db.SaveLastAction(storage.UndoAction{Type: storage.ActionDelete, ProjectName: selectedEntry.ProjectName, Entry: selectedEntry})
165+
164166
fmt.Println()
165167
ui.PrintSuccess(ui.EmojiSuccess, "Entry deleted successfully")
166168
ui.NewlineBelow()

cmd/entries/manual.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,8 @@ func ManualCmd() *cobra.Command {
360360
os.Exit(1)
361361
}
362362

363+
db.SaveLastAction(storage.UndoAction{Type: storage.ActionManual, EntryID: entry.ID, ProjectName: entry.ProjectName})
364+
363365
duration := entry.Duration()
364366
fmt.Println()
365367
ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created manual entry for %s", ui.Bold(entry.ProjectName)))

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Track time effortlessly with automatic project detection and simple commands.`,
3939
cmd.Flags().BoolP("version", "v", false, "version for tmpo")
4040

4141
// Utilities
42+
cmd.AddCommand(utilities.UndoCmd())
4243
cmd.AddCommand(utilities.VersionCmd())
4344

4445
// Tracking

cmd/tracking/pause.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ func PauseCmd() *cobra.Command {
4444
os.Exit(1)
4545
}
4646

47+
db.SaveLastAction(storage.UndoAction{Type: storage.ActionPause, EntryID: running.ID, ProjectName: running.ProjectName})
48+
4749
duration := time.Since(running.StartTime)
4850

4951
ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Paused tracking %s", ui.Bold(running.ProjectName)))

cmd/tracking/resume.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ func ResumeCmd() *cobra.Command {
6868
os.Exit(1)
6969
}
7070

71+
db.SaveLastAction(storage.UndoAction{Type: storage.ActionResume, EntryID: entry.ID, ProjectName: entry.ProjectName})
72+
7173
ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Resumed tracking time for %s", ui.Bold(entry.ProjectName)))
7274

7375
if entry.Description != "" {

cmd/tracking/start.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ func StartCmd() *cobra.Command {
7474
os.Exit(1)
7575
}
7676

77+
db.SaveLastAction(storage.UndoAction{Type: storage.ActionStart, EntryID: entry.ID, ProjectName: entry.ProjectName})
78+
7779
ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Started tracking time for %s", ui.Bold(entry.ProjectName)))
7880

7981
// communicate config source to user

cmd/tracking/stop.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ func StopCmd() *cobra.Command {
4343
os.Exit(1)
4444
}
4545

46+
db.SaveLastAction(storage.UndoAction{Type: storage.ActionStop, EntryID: running.ID, ProjectName: running.ProjectName})
47+
4648
duration := time.Since(running.StartTime)
4749

4850
ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Stopped tracking %s", ui.Bold(running.ProjectName)))

cmd/utilities/undo.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package utilities
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/DylanDevelops/tmpo/internal/storage"
8+
"github.com/DylanDevelops/tmpo/internal/ui"
9+
"github.com/manifoldco/promptui"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var actionDescriptions = map[storage.ActionType]string{
14+
storage.ActionStop: "Stopped tracking",
15+
storage.ActionPause: "Paused tracking",
16+
storage.ActionStart: "Started tracking",
17+
storage.ActionResume: "Resumed tracking",
18+
storage.ActionManual: "Created manual entry for",
19+
storage.ActionDelete: "Deleted entry for",
20+
}
21+
22+
func UndoCmd() *cobra.Command {
23+
cmd := &cobra.Command{
24+
Use: "undo",
25+
Short: "Undo the previous action",
26+
Long: `Undo the previous action in case of a mistake or in need of a rollback.`,
27+
Run: func(cmd *cobra.Command, args []string) {
28+
ui.NewlineAbove()
29+
30+
db, err := storage.Initialize()
31+
if err != nil {
32+
ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err))
33+
os.Exit(1)
34+
}
35+
defer db.Close()
36+
37+
action, err := db.GetLastAction()
38+
if err != nil {
39+
ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err))
40+
os.Exit(1)
41+
}
42+
43+
if action == nil {
44+
ui.PrintWarning(ui.EmojiWarning, "Nothing to undo.")
45+
ui.NewlineBelow()
46+
return
47+
}
48+
49+
ui.PrintInfo(0, ui.EmojiUndo+" Last action", undoActionDescription(action))
50+
fmt.Println()
51+
52+
confirmPrompt := promptui.Prompt{
53+
Label: "Undo this action? [y/N]",
54+
IsConfirm: true,
55+
}
56+
if _, err := confirmPrompt.Run(); err != nil {
57+
ui.PrintWarning(ui.EmojiWarning, "Undo cancelled.")
58+
ui.NewlineBelow()
59+
return
60+
}
61+
62+
if err := applyUndo(db, action); err != nil {
63+
ui.PrintError(ui.EmojiError, fmt.Sprintf("undo failed: %v", err))
64+
ui.NewlineBelow()
65+
os.Exit(1)
66+
}
67+
68+
// not fatal if fails
69+
db.ClearLastAction()
70+
71+
ui.PrintSuccess(ui.EmojiUndo, "Undo successful.")
72+
ui.NewlineBelow()
73+
},
74+
}
75+
76+
return cmd
77+
}
78+
79+
func undoActionDescription(action *storage.UndoAction) string {
80+
if prefix, ok := actionDescriptions[action.Type]; ok {
81+
return fmt.Sprintf("%s %s", prefix, ui.Bold(action.ProjectName))
82+
}
83+
return fmt.Sprintf("Unknown action: %s", action.Type)
84+
}
85+
86+
func applyUndo(db *storage.Database, action *storage.UndoAction) error {
87+
switch action.Type {
88+
case storage.ActionStop, storage.ActionPause:
89+
running, err := db.GetRunningEntry()
90+
if err != nil {
91+
return fmt.Errorf("checking for running entry: %w", err)
92+
}
93+
if running != nil {
94+
return fmt.Errorf("a timer is already running for %s — stop it first with 'tmpo stop'", running.ProjectName)
95+
}
96+
return db.UncompleteEntry(action.EntryID)
97+
98+
case storage.ActionStart, storage.ActionResume, storage.ActionManual:
99+
return db.DeleteTimeEntry(action.EntryID)
100+
101+
case storage.ActionDelete:
102+
if action.Entry == nil {
103+
return fmt.Errorf("no entry snapshot available to restore")
104+
}
105+
return db.RestoreDeletedEntry(action.Entry)
106+
107+
default:
108+
return fmt.Errorf("unknown action type: %s", action.Type)
109+
}
110+
}

cmd/utilities/undo_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package utilities
2+
3+
import (
4+
"testing"
5+
6+
"github.com/DylanDevelops/tmpo/internal/storage"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func setupUndoTestDB(t *testing.T) *storage.Database {
12+
t.Helper()
13+
tmpHome := t.TempDir()
14+
t.Setenv("HOME", tmpHome)
15+
t.Setenv("USERPROFILE", tmpHome)
16+
t.Setenv("TMPO_DEV", "1")
17+
db, err := storage.Initialize()
18+
require.NoError(t, err)
19+
t.Cleanup(func() { db.Close() })
20+
return db
21+
}
22+
23+
func TestUndoActionDescription(t *testing.T) {
24+
tests := []struct {
25+
actionType storage.ActionType
26+
contains string
27+
}{
28+
{storage.ActionStop, "Stopped tracking"},
29+
{storage.ActionPause, "Paused tracking"},
30+
{storage.ActionStart, "Started tracking"},
31+
{storage.ActionResume, "Resumed tracking"},
32+
{storage.ActionManual, "Created manual entry for"},
33+
{storage.ActionDelete, "Deleted entry for"},
34+
}
35+
36+
for _, tt := range tests {
37+
t.Run(string(tt.actionType), func(t *testing.T) {
38+
action := &storage.UndoAction{Type: tt.actionType, ProjectName: "proj"}
39+
desc := undoActionDescription(action)
40+
assert.Contains(t, desc, tt.contains)
41+
assert.Contains(t, desc, "proj")
42+
})
43+
}
44+
}
45+
46+
func TestUndoActionDescription_Unknown(t *testing.T) {
47+
action := &storage.UndoAction{Type: "something_new", ProjectName: "proj"}
48+
desc := undoActionDescription(action)
49+
assert.Contains(t, desc, "Unknown action")
50+
assert.Contains(t, desc, "something_new")
51+
}
52+
53+
func TestApplyUndo_Stop_ErrorWhenTimerAlreadyRunning(t *testing.T) {
54+
db := setupUndoTestDB(t)
55+
56+
stopped, err := db.CreateEntry("proj", "", nil, nil)
57+
require.NoError(t, err)
58+
require.NoError(t, db.StopEntry(stopped.ID))
59+
60+
_, err = db.CreateEntry("other", "", nil, nil)
61+
require.NoError(t, err)
62+
63+
action := &storage.UndoAction{Type: storage.ActionStop, EntryID: stopped.ID, ProjectName: "proj"}
64+
err = applyUndo(db, action)
65+
assert.Error(t, err)
66+
assert.Contains(t, err.Error(), "timer is already running")
67+
}
68+
69+
func TestApplyUndo_Pause_ErrorWhenTimerAlreadyRunning(t *testing.T) {
70+
db := setupUndoTestDB(t)
71+
72+
stopped, err := db.CreateEntry("proj", "", nil, nil)
73+
require.NoError(t, err)
74+
require.NoError(t, db.StopEntry(stopped.ID))
75+
76+
_, err = db.CreateEntry("other", "", nil, nil)
77+
require.NoError(t, err)
78+
79+
action := &storage.UndoAction{Type: storage.ActionPause, EntryID: stopped.ID, ProjectName: "proj"}
80+
err = applyUndo(db, action)
81+
assert.Error(t, err)
82+
assert.Contains(t, err.Error(), "timer is already running")
83+
}
84+
85+
func TestApplyUndo_Delete_ErrorWhenNoSnapshot(t *testing.T) {
86+
db := setupUndoTestDB(t)
87+
88+
action := &storage.UndoAction{Type: storage.ActionDelete, ProjectName: "proj", Entry: nil}
89+
err := applyUndo(db, action)
90+
assert.Error(t, err)
91+
assert.Contains(t, err.Error(), "no entry snapshot")
92+
}
93+
94+
func TestApplyUndo_UnknownType_ReturnsError(t *testing.T) {
95+
db := setupUndoTestDB(t)
96+
97+
action := &storage.UndoAction{Type: "bogus", ProjectName: "proj"}
98+
err := applyUndo(db, action)
99+
assert.Error(t, err)
100+
assert.Contains(t, err.Error(), "unknown action type")
101+
}

internal/storage/undo.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package storage
2+
3+
import (
4+
"database/sql"
5+
"encoding/json"
6+
"fmt"
7+
"time"
8+
)
9+
10+
type ActionType string
11+
12+
const (
13+
ActionStop ActionType = "stop"
14+
ActionStart ActionType = "start"
15+
ActionPause ActionType = "pause"
16+
ActionResume ActionType = "resume"
17+
ActionDelete ActionType = "delete"
18+
ActionManual ActionType = "manual"
19+
)
20+
21+
const lastActionKey = "last_action"
22+
23+
type UndoAction struct {
24+
Type ActionType `json:"type"`
25+
EntryID int64 `json:"entry_id,omitempty"`
26+
ProjectName string `json:"project_name,omitempty"`
27+
Entry *TimeEntry `json:"entry,omitempty"`
28+
}
29+
30+
func (d *Database) SaveLastAction(action UndoAction) error {
31+
data, err := json.Marshal(action)
32+
if err != nil {
33+
return fmt.Errorf("failed to serialize action: %w", err)
34+
}
35+
_, err = d.db.Exec(
36+
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
37+
lastActionKey,
38+
string(data),
39+
time.Now().UTC(),
40+
)
41+
if err != nil {
42+
return fmt.Errorf("failed to save last action: %w", err)
43+
}
44+
return nil
45+
}
46+
47+
func (d *Database) GetLastAction() (*UndoAction, error) {
48+
var value string
49+
err := d.db.QueryRow("SELECT value FROM settings WHERE key = ?", lastActionKey).Scan(&value)
50+
if err == sql.ErrNoRows {
51+
return nil, nil
52+
}
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to get last action: %w", err)
55+
}
56+
var action UndoAction
57+
if err := json.Unmarshal([]byte(value), &action); err != nil {
58+
return nil, fmt.Errorf("failed to parse last action: %w", err)
59+
}
60+
return &action, nil
61+
}
62+
63+
func (d *Database) ClearLastAction() error {
64+
_, err := d.db.Exec("DELETE FROM settings WHERE key = ?", lastActionKey)
65+
if err != nil {
66+
return fmt.Errorf("failed to clear last action: %w", err)
67+
}
68+
return nil
69+
}
70+
71+
// UncompleteEntry clears the end_time of an entry, resuming it as a running timer.
72+
func (d *Database) UncompleteEntry(id int64) error {
73+
result, err := d.db.Exec("UPDATE time_entries SET end_time = NULL WHERE id = ?", id)
74+
if err != nil {
75+
return fmt.Errorf("failed to uncomplete entry: %w", err)
76+
}
77+
rows, err := result.RowsAffected()
78+
if err != nil {
79+
return fmt.Errorf("failed to uncomplete entry: %w", err)
80+
}
81+
if rows == 0 {
82+
return fmt.Errorf("entry %d not found", id)
83+
}
84+
return nil
85+
}
86+
87+
// RestoreDeletedEntry re-inserts a previously deleted entry preserving its original ID.
88+
func (d *Database) RestoreDeletedEntry(entry *TimeEntry) error {
89+
var endTime sql.NullTime
90+
if entry.EndTime != nil {
91+
endTime = sql.NullTime{Time: entry.EndTime.UTC(), Valid: true}
92+
}
93+
var rate sql.NullFloat64
94+
if entry.HourlyRate != nil {
95+
rate = sql.NullFloat64{Float64: *entry.HourlyRate, Valid: true}
96+
}
97+
var milestone sql.NullString
98+
if entry.MilestoneName != nil {
99+
milestone = sql.NullString{String: *entry.MilestoneName, Valid: true}
100+
}
101+
_, err := d.db.Exec(
102+
"INSERT INTO time_entries (id, project_name, start_time, end_time, description, hourly_rate, milestone_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
103+
entry.ID,
104+
entry.ProjectName,
105+
entry.StartTime.UTC(),
106+
endTime,
107+
entry.Description,
108+
rate,
109+
milestone,
110+
)
111+
if err != nil {
112+
return fmt.Errorf("failed to restore entry: %w", err)
113+
}
114+
return nil
115+
}

0 commit comments

Comments
 (0)