Add ticket time tracking with reports and entry management.
Support start/stop timer, manual durations, grouped reports, and per-ticket edit/delete of time entries with undo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,12 +21,13 @@ type Executor interface {
|
||||
|
||||
// History is an undo/redo stack for Command values, optionally persisted to disk.
|
||||
type History struct {
|
||||
mu sync.Mutex
|
||||
undoS []Command
|
||||
redoS []Command
|
||||
path string
|
||||
store ticket.Store
|
||||
max int
|
||||
mu sync.Mutex
|
||||
undoS []Command
|
||||
redoS []Command
|
||||
path string
|
||||
store ticket.Store
|
||||
timeStore ticket.TimeStore
|
||||
max int
|
||||
}
|
||||
|
||||
// NewHistory creates an empty in-memory command history (cap MaxHistory).
|
||||
@@ -36,13 +37,15 @@ func NewHistory() *History {
|
||||
|
||||
// OpenHistory loads a persisted history from path (DataDir/history.json) or
|
||||
// starts empty if the file is missing. Subsequent Do/Undo/Redo write back.
|
||||
func OpenHistory(path string, store ticket.Store) (*History, error) {
|
||||
// timeStore may be nil if time-entry commands are unused.
|
||||
func OpenHistory(path string, store ticket.Store, timeStore ticket.TimeStore) (*History, error) {
|
||||
h := &History{
|
||||
path: path,
|
||||
store: store,
|
||||
max: MaxHistory,
|
||||
path: path,
|
||||
store: store,
|
||||
timeStore: timeStore,
|
||||
max: MaxHistory,
|
||||
}
|
||||
undo, redo, err := loadHistoryFile(path, store)
|
||||
undo, redo, err := loadHistoryFile(path, store, timeStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+57
-14
@@ -21,17 +21,23 @@ type persistedFile struct {
|
||||
}
|
||||
|
||||
type persistedCmd struct {
|
||||
Type string `json:"type"`
|
||||
Ticket *ticket.Ticket `json:"ticket,omitempty"`
|
||||
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
|
||||
Before *ticket.Ticket `json:"before,omitempty"`
|
||||
After *ticket.Ticket `json:"after,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Ticket *ticket.Ticket `json:"ticket,omitempty"`
|
||||
Snapshot *ticket.Ticket `json:"snapshot,omitempty"`
|
||||
Before *ticket.Ticket `json:"before,omitempty"`
|
||||
After *ticket.Ticket `json:"after,omitempty"`
|
||||
TimeEntry *ticket.TimeEntry `json:"timeEntry,omitempty"`
|
||||
TimeBefore *ticket.TimeEntry `json:"timeBefore,omitempty"`
|
||||
TimeAfter *ticket.TimeEntry `json:"timeAfter,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
typeCreate = "create"
|
||||
typeDelete = "delete"
|
||||
typeUpdate = "update"
|
||||
typeCreate = "create"
|
||||
typeDelete = "delete"
|
||||
typeUpdate = "update"
|
||||
typeAddTime = "add_time"
|
||||
typeRemoveTime = "remove_time"
|
||||
typeUpdateTime = "update_time"
|
||||
)
|
||||
|
||||
// HistoryPath returns DataDir/history.json.
|
||||
@@ -54,12 +60,25 @@ func encodeCmd(cmd Command) (persistedCmd, error) {
|
||||
case *UpdateTicket:
|
||||
b, a := c.Before, c.After
|
||||
return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil
|
||||
case *AddTimeEntry:
|
||||
e := c.Entry
|
||||
if c.created.ID != "" {
|
||||
e = c.created
|
||||
}
|
||||
cp := e
|
||||
return persistedCmd{Type: typeAddTime, TimeEntry: &cp}, nil
|
||||
case *RemoveTimeEntry:
|
||||
cp := c.Snapshot
|
||||
return persistedCmd{Type: typeRemoveTime, TimeEntry: &cp}, nil
|
||||
case *UpdateTimeEntry:
|
||||
b, a := c.Before, c.After
|
||||
return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil
|
||||
default:
|
||||
return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
|
||||
func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore) (Command, error) {
|
||||
switch rec.Type {
|
||||
case typeCreate:
|
||||
if rec.Ticket == nil {
|
||||
@@ -76,6 +95,30 @@ func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) {
|
||||
return nil, fmt.Errorf("update record missing before/after")
|
||||
}
|
||||
return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil
|
||||
case typeAddTime:
|
||||
if rec.TimeEntry == nil {
|
||||
return nil, fmt.Errorf("add_time record missing entry")
|
||||
}
|
||||
if timeStore == nil {
|
||||
return nil, fmt.Errorf("add_time: time store not configured")
|
||||
}
|
||||
return &AddTimeEntry{Store: timeStore, Entry: *rec.TimeEntry, created: *rec.TimeEntry}, nil
|
||||
case typeRemoveTime:
|
||||
if rec.TimeEntry == nil {
|
||||
return nil, fmt.Errorf("remove_time record missing entry")
|
||||
}
|
||||
if timeStore == nil {
|
||||
return nil, fmt.Errorf("remove_time: time store not configured")
|
||||
}
|
||||
return &RemoveTimeEntry{Store: timeStore, Snapshot: *rec.TimeEntry}, nil
|
||||
case typeUpdateTime:
|
||||
if rec.TimeBefore == nil || rec.TimeAfter == nil {
|
||||
return nil, fmt.Errorf("update_time record missing before/after")
|
||||
}
|
||||
if timeStore == nil {
|
||||
return nil, fmt.Errorf("update_time: time store not configured")
|
||||
}
|
||||
return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown command type %q", rec.Type)
|
||||
}
|
||||
@@ -93,10 +136,10 @@ func encodeStack(cmds []Command) ([]persistedCmd, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decodeStack(recs []persistedCmd, store ticket.Store) ([]Command, error) {
|
||||
func decodeStack(recs []persistedCmd, store ticket.Store, timeStore ticket.TimeStore) ([]Command, error) {
|
||||
out := make([]Command, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
cmd, err := decodeCmd(rec, store)
|
||||
cmd, err := decodeCmd(rec, store, timeStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -154,7 +197,7 @@ func (h *History) persistLocked() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err error) {
|
||||
func loadHistoryFile(path string, store ticket.Store, timeStore ticket.TimeStore) (undo, redo []Command, err error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -169,11 +212,11 @@ func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse history: %w", err)
|
||||
}
|
||||
undo, err = decodeStack(file.Undo, store)
|
||||
undo, err = decodeStack(file.Undo, store, timeStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
redo, err = decodeStack(file.Redo, store)
|
||||
redo, err = decodeStack(file.Redo, store, timeStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
|
||||
store := ticket.NewJSONStore(dir)
|
||||
path := command.HistoryPath(dir)
|
||||
|
||||
h1, err := command.OpenHistory(path, store)
|
||||
h1, err := command.OpenHistory(path, store, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
|
||||
t.Fatalf("history file missing: %v", err)
|
||||
}
|
||||
|
||||
h2, err := command.OpenHistory(path, store)
|
||||
h2, err := command.OpenHistory(path, store, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func TestHistoryPersistsAcrossOpen(t *testing.T) {
|
||||
func TestHistoryCapsAtMax(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store)
|
||||
h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// AddTimeEntry creates a time entry; Undo deletes it.
|
||||
type AddTimeEntry struct {
|
||||
Store ticket.TimeStore
|
||||
Entry ticket.TimeEntry
|
||||
created ticket.TimeEntry
|
||||
}
|
||||
|
||||
func (c *AddTimeEntry) Name() string { return "add time entry" }
|
||||
|
||||
func (c *AddTimeEntry) Execute(ctx context.Context) error {
|
||||
_ = ctx
|
||||
created, err := c.Store.Create(c.Entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.created = created
|
||||
c.Entry = created
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AddTimeEntry) Undo(ctx context.Context) error {
|
||||
_ = ctx
|
||||
id := c.created.ID
|
||||
if id == "" {
|
||||
id = c.Entry.ID
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("add time undo: missing entry id")
|
||||
}
|
||||
return c.Store.Delete(id)
|
||||
}
|
||||
|
||||
// RemoveTimeEntry deletes a time entry; Undo restores the snapshot.
|
||||
type RemoveTimeEntry struct {
|
||||
Store ticket.TimeStore
|
||||
Snapshot ticket.TimeEntry
|
||||
}
|
||||
|
||||
func (c *RemoveTimeEntry) Name() string { return "remove time entry" }
|
||||
|
||||
func (c *RemoveTimeEntry) Execute(ctx context.Context) error {
|
||||
_ = ctx
|
||||
if c.Snapshot.ID == "" {
|
||||
return fmt.Errorf("remove time: missing entry id")
|
||||
}
|
||||
return c.Store.Delete(c.Snapshot.ID)
|
||||
}
|
||||
|
||||
func (c *RemoveTimeEntry) Undo(ctx context.Context) error {
|
||||
_ = ctx
|
||||
_, err := c.Store.Create(c.Snapshot)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTimeEntry applies a new entry state; Undo restores Before.
|
||||
type UpdateTimeEntry struct {
|
||||
Store ticket.TimeStore
|
||||
Before ticket.TimeEntry
|
||||
After ticket.TimeEntry
|
||||
}
|
||||
|
||||
func (c *UpdateTimeEntry) Name() string { return "update time entry" }
|
||||
|
||||
func (c *UpdateTimeEntry) Execute(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return c.Store.Update(c.After)
|
||||
}
|
||||
|
||||
func (c *UpdateTimeEntry) Undo(ctx context.Context) error {
|
||||
_ = ctx
|
||||
return c.Store.Update(c.Before)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package command_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestUpdateTimeEntryUndo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONTimeStore(dir)
|
||||
h := command.NewHistory()
|
||||
|
||||
created, err := store.Create(ticket.TimeEntry{
|
||||
TicketID: "t1",
|
||||
ProjectID: "inbox",
|
||||
Start: time.Now().UTC().Add(-time.Hour),
|
||||
Duration: time.Hour,
|
||||
Source: ticket.SourceManual,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after := created
|
||||
after.Duration = 30 * time.Minute
|
||||
after.End = after.Start.Add(after.Duration)
|
||||
if err := h.Do(&command.UpdateTimeEntry{Store: store, Before: created, After: after}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := store.ListByTicket("t1")
|
||||
if len(list) != 1 || list[0].Duration != 30*time.Minute {
|
||||
t.Fatalf("after update: %+v", list)
|
||||
}
|
||||
if err := h.Undo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ = store.ListByTicket("t1")
|
||||
if len(list) != 1 || list[0].Duration != time.Hour {
|
||||
t.Fatalf("after undo: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTimeEntryUndo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONTimeStore(dir)
|
||||
h := command.NewHistory()
|
||||
|
||||
created, err := store.Create(ticket.TimeEntry{
|
||||
TicketID: "t1",
|
||||
ProjectID: "inbox",
|
||||
Start: time.Now().UTC(),
|
||||
Duration: 15 * time.Minute,
|
||||
Source: ticket.SourceTimer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Do(&command.RemoveTimeEntry{Store: store, Snapshot: created}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := store.List()
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected empty after remove, got %d", len(list))
|
||||
}
|
||||
if err := h.Undo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ = store.List()
|
||||
if len(list) != 1 || list[0].ID != created.ID {
|
||||
t.Fatalf("after undo: %+v", list)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user