From ef61664607d3a200f723177f84f234eb046a04f7 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sat, 11 Jul 2026 14:19:10 -0700 Subject: [PATCH 1/5] Add cancel command --- cmd/root.go | 1 + cmd/tracking/cancel.go | 57 +++++++++++++++++++++++++++++++++++++ cmd/utilities/undo.go | 3 +- internal/storage/db.go | 10 +++++++ internal/storage/db_test.go | 17 +++++++++++ internal/storage/undo.go | 1 + internal/ui/ui.go | 1 + 7 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 cmd/tracking/cancel.go diff --git a/cmd/root.go b/cmd/root.go index 999fbf2..b882975 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -45,6 +45,7 @@ Track time effortlessly with automatic project detection and simple commands.`, // Tracking cmd.AddCommand(tracking.StartCmd()) cmd.AddCommand(tracking.StopCmd()) + cmd.AddCommand(tracking.CancelCmd()) cmd.AddCommand(tracking.PauseCmd()) cmd.AddCommand(tracking.ResumeCmd()) cmd.AddCommand(tracking.StatusCmd()) diff --git a/cmd/tracking/cancel.go b/cmd/tracking/cancel.go new file mode 100644 index 0000000..42e8f27 --- /dev/null +++ b/cmd/tracking/cancel.go @@ -0,0 +1,57 @@ +package tracking + +import ( + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +func CancelCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cancel", + Short: "Cancel the running time entry", + Long: "Stops and cancels the running time tracking 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) + } + + 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.") + os.Exit(0) + } + + err = db.CancelEntry(running.ID) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + db.SaveLastAction(storage.UndoAction{ + Type: storage.ActionCancel, + EntryID: running.ID, + ProjectName: running.ProjectName, + }) + + ui.PrintSuccess(ui.EmojiCancel, fmt.Sprintf("Cancelled tracking %s", ui.Bold(running.ProjectName))) + ui.PrintMuted(2, "Run 'tmpo start' to create a new time tracking session.") + + ui.NewlineBelow() + }, + } + + return cmd +} diff --git a/cmd/utilities/undo.go b/cmd/utilities/undo.go index cefbc44..8e8eeab 100644 --- a/cmd/utilities/undo.go +++ b/cmd/utilities/undo.go @@ -12,6 +12,7 @@ import ( var actionDescriptions = map[storage.ActionType]string{ storage.ActionStop: "Stopped tracking", + storage.ActionCancel: "Cancelled tracking", storage.ActionPause: "Paused tracking", storage.ActionStart: "Started tracking", storage.ActionResume: "Resumed tracking", @@ -99,7 +100,7 @@ func applyUndo(db *storage.Database, action *storage.UndoAction) error { case storage.ActionStart, storage.ActionResume, storage.ActionManual: return db.DeleteTimeEntry(action.EntryID) - case storage.ActionDelete: + case storage.ActionDelete, storage.ActionCancel: if action.Entry == nil { return fmt.Errorf("no entry snapshot available to restore") } diff --git a/internal/storage/db.go b/internal/storage/db.go index b31eb31..5059d67 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -319,6 +319,16 @@ func (d *Database) StopEntry(id int64) error { return nil } +func (d *Database) CancelEntry(id int64) error { + err := d.DeleteTimeEntry(id) + + if err != nil { + return fmt.Errorf("failed to cancel time entry: %w", err) + } + + return nil +} + func (d *Database) GetEntry(id int64) (*TimeEntry, error) { var entry TimeEntry var endTime sql.NullTime diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index a60c4b1..6124ac5 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -291,6 +291,23 @@ func TestStopEntry(t *testing.T) { assert.NotNil(t, stopped.EndTime) } +func TestCancelEntry(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + entry, err := db.CreateEntry("test-project", "test", nil, nil) + assert.NoError(t, err) + assert.NotNil(t, entry) + + err = db.CancelEntry(entry.ID) + assert.NoError(t, err) + + // Verify entry was cancelled + cancelled, err := db.GetEntry(entry.ID) + assert.Error(t, err) + assert.Nil(t, cancelled) +} + func TestGetEntry(t *testing.T) { db := setupTestDB(t) defer db.Close() diff --git a/internal/storage/undo.go b/internal/storage/undo.go index e40296a..d6e0d8d 100644 --- a/internal/storage/undo.go +++ b/internal/storage/undo.go @@ -11,6 +11,7 @@ type ActionType string const ( ActionStop ActionType = "stop" + ActionCancel ActionType = "cancel" ActionStart ActionType = "start" ActionPause ActionType = "pause" ActionResume ActionType = "resume" diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 7cb467c..a6e4085 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -37,6 +37,7 @@ const ( const ( EmojiStart = "✨" EmojiStop = "🛑" + EmojiCancel = "🚫" EmojiStatus = "⏱️" EmojiStats = "📊" EmojiLog = "📝" From 1eeb61dcf38e0268ee2b8db0508664e9b0f285e3 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sat, 11 Jul 2026 14:28:07 -0700 Subject: [PATCH 2/5] Fixed an error that would trigger when undoing --- cmd/tracking/cancel.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/tracking/cancel.go b/cmd/tracking/cancel.go index 42e8f27..bdec028 100644 --- a/cmd/tracking/cancel.go +++ b/cmd/tracking/cancel.go @@ -42,8 +42,8 @@ func CancelCmd() *cobra.Command { db.SaveLastAction(storage.UndoAction{ Type: storage.ActionCancel, - EntryID: running.ID, ProjectName: running.ProjectName, + Entry: running, }) ui.PrintSuccess(ui.EmojiCancel, fmt.Sprintf("Cancelled tracking %s", ui.Bold(running.ProjectName))) From d81c5be3aa098074ce12c07cb6c5b138d39f47b8 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sat, 11 Jul 2026 14:34:24 -0700 Subject: [PATCH 3/5] Fixed formatting and changed wording --- cmd/tracking/cancel.go | 3 ++- cmd/tracking/stop.go | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/tracking/cancel.go b/cmd/tracking/cancel.go index bdec028..8e4e84e 100644 --- a/cmd/tracking/cancel.go +++ b/cmd/tracking/cancel.go @@ -31,6 +31,7 @@ func CancelCmd() *cobra.Command { if running == nil { ui.PrintWarning(ui.EmojiWarning, "No active time tracking session.") + ui.NewlineBelow() os.Exit(0) } @@ -47,7 +48,7 @@ func CancelCmd() *cobra.Command { }) ui.PrintSuccess(ui.EmojiCancel, fmt.Sprintf("Cancelled tracking %s", ui.Bold(running.ProjectName))) - ui.PrintMuted(2, "Run 'tmpo start' to create a new time tracking session.") + ui.PrintMuted(4, "If this was a mistake, run `tmpo undo`.") ui.NewlineBelow() }, diff --git a/cmd/tracking/stop.go b/cmd/tracking/stop.go index 9c72f25..a6876bd 100644 --- a/cmd/tracking/stop.go +++ b/cmd/tracking/stop.go @@ -34,6 +34,7 @@ func StopCmd() *cobra.Command { if running == nil { ui.PrintWarning(ui.EmojiWarning, "No active time tracking session.") + ui.NewlineBelow() os.Exit(0) } From a1c7abb75e87d7bbdd27fa576761affb867661d2 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sat, 11 Jul 2026 14:35:41 -0700 Subject: [PATCH 4/5] Add usage docs --- docs/usage.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index a850089..395d38f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,6 +34,14 @@ Stop the currently running time entry. tmpo stop ``` +### `tmpo cancel` + +Cancel the currently running time entry. + +```bash +tmpo cancel +``` + ### `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`. From 4599edacde91a0c5f5e8d53e4fcb020e14797059 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 12 Jul 2026 11:45:01 -1000 Subject: [PATCH 5/5] Fix resource cleanup and refactor timer validation --- cmd/tracking/cancel.go | 2 ++ cmd/utilities/undo.go | 25 +++++++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/cmd/tracking/cancel.go b/cmd/tracking/cancel.go index 8e4e84e..333867e 100644 --- a/cmd/tracking/cancel.go +++ b/cmd/tracking/cancel.go @@ -23,6 +23,8 @@ func CancelCmd() *cobra.Command { os.Exit(1) } + defer db.Close() + running, err := db.GetRunningEntry() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) diff --git a/cmd/utilities/undo.go b/cmd/utilities/undo.go index 8e8eeab..b1176b0 100644 --- a/cmd/utilities/undo.go +++ b/cmd/utilities/undo.go @@ -88,12 +88,8 @@ func undoActionDescription(action *storage.UndoAction) string { 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) + if err := ensureNoRunningTimer(db); err != nil { + return err } return db.UncompleteEntry(action.EntryID) @@ -104,6 +100,12 @@ func applyUndo(db *storage.Database, action *storage.UndoAction) error { if action.Entry == nil { return fmt.Errorf("no entry snapshot available to restore") } + + if action.Entry.EndTime == nil { + if err := ensureNoRunningTimer(db); err != nil { + return err + } + } return db.RestoreDeletedEntry(action.Entry) case storage.ActionEdit: @@ -116,3 +118,14 @@ func applyUndo(db *storage.Database, action *storage.UndoAction) error { return fmt.Errorf("unknown action type: %s", action.Type) } } + +func ensureNoRunningTimer(db *storage.Database) error { + 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 nil +}