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:
Skruecken
2026-10-19 11:05:10 +02:00
co-authored by Cursor
parent 41feba11de
commit bfc1cbd4d9
23 changed files with 2149 additions and 94 deletions
+57 -14
View File
@@ -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
}