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:
@@ -0,0 +1,282 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
timeEntriesFileName = "time_entries.json"
|
||||
activeTimerFileName = "active_timer.json"
|
||||
)
|
||||
|
||||
// JSONTimeStore persists time entries and the active timer under DataDir.
|
||||
type JSONTimeStore struct {
|
||||
dataDir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewJSONTimeStore creates a time store rooted at dataDir.
|
||||
func NewJSONTimeStore(dataDir string) *JSONTimeStore {
|
||||
return &JSONTimeStore{dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (s *JSONTimeStore) entriesPath() string {
|
||||
return filepath.Join(s.dataDir, timeEntriesFileName)
|
||||
}
|
||||
|
||||
func (s *JSONTimeStore) timerPath() string {
|
||||
return filepath.Join(s.dataDir, activeTimerFileName)
|
||||
}
|
||||
|
||||
// List returns all time entries.
|
||||
func (s *JSONTimeStore) List() ([]TimeEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.readUnlocked()
|
||||
}
|
||||
|
||||
// ListByTicket returns entries for one ticket.
|
||||
func (s *JSONTimeStore) ListByTicket(ticketID string) ([]TimeEntry, error) {
|
||||
all, err := s.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TimeEntry, 0)
|
||||
for _, e := range all {
|
||||
if e.TicketID == ticketID {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Create adds a time entry, assigning an ID if missing.
|
||||
func (s *JSONTimeStore) Create(e TimeEntry) (TimeEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if e.TicketID == "" {
|
||||
return TimeEntry{}, fmt.Errorf("ticketId is required")
|
||||
}
|
||||
if e.Duration <= 0 && !e.End.IsZero() && !e.Start.IsZero() {
|
||||
e.Duration = e.End.Sub(e.Start)
|
||||
}
|
||||
if e.Duration <= 0 {
|
||||
return TimeEntry{}, fmt.Errorf("duration must be positive")
|
||||
}
|
||||
if e.ID == "" {
|
||||
e.ID = uuid.NewString()
|
||||
}
|
||||
if e.Source == "" {
|
||||
e.Source = SourceManual
|
||||
}
|
||||
if e.Start.IsZero() {
|
||||
e.Start = time.Now().UTC()
|
||||
}
|
||||
if e.End.IsZero() {
|
||||
e.End = e.Start.Add(e.Duration)
|
||||
}
|
||||
|
||||
entries, err := s.readUnlocked()
|
||||
if err != nil {
|
||||
return TimeEntry{}, err
|
||||
}
|
||||
entries = append(entries, e)
|
||||
if err := s.writeUnlocked(entries); err != nil {
|
||||
return TimeEntry{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Update replaces an existing time entry by ID.
|
||||
func (s *JSONTimeStore) Update(e TimeEntry) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if e.ID == "" {
|
||||
return fmt.Errorf("time entry id is required")
|
||||
}
|
||||
if e.Duration <= 0 {
|
||||
return fmt.Errorf("duration must be positive")
|
||||
}
|
||||
if e.Start.IsZero() {
|
||||
e.Start = time.Now().UTC()
|
||||
}
|
||||
e.End = e.Start.Add(e.Duration)
|
||||
|
||||
entries, err := s.readUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for i := range entries {
|
||||
if entries[i].ID == e.ID {
|
||||
entries[i] = e
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("time entry %s: not found", e.ID)
|
||||
}
|
||||
return s.writeUnlocked(entries)
|
||||
}
|
||||
|
||||
// Delete removes a time entry by ID.
|
||||
func (s *JSONTimeStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entries, err := s.readUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := make([]TimeEntry, 0, len(entries))
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("time entry %s: not found", id)
|
||||
}
|
||||
return s.writeUnlocked(out)
|
||||
}
|
||||
|
||||
// SumByTicket returns total duration for a ticket.
|
||||
func (s *JSONTimeStore) SumByTicket(ticketID string) (time.Duration, error) {
|
||||
entries, err := s.ListByTicket(ticketID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var sum time.Duration
|
||||
for _, e := range entries {
|
||||
sum += e.Duration
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// LoadActiveTimer returns the running timer, or nil if none.
|
||||
func (s *JSONTimeStore) LoadActiveTimer() (*ActiveTimer, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(s.timerPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read active timer: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var t ActiveTimer
|
||||
if err := json.Unmarshal(data, &t); err != nil {
|
||||
return nil, fmt.Errorf("parse active timer: %w", err)
|
||||
}
|
||||
if t.TicketID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// SaveActiveTimer writes the active timer, or removes the file when t is nil.
|
||||
func (s *JSONTimeStore) SaveActiveTimer(t *ActiveTimer) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.timerPath()
|
||||
if t == nil {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("clear active timer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(t, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal active timer: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "timer-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp timer: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write temp timer: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp timer: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("rename timer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *JSONTimeStore) readUnlocked() ([]TimeEntry, error) {
|
||||
path := s.entriesPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []TimeEntry{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return []TimeEntry{}, nil
|
||||
}
|
||||
var entries []TimeEntry
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []TimeEntry{}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *JSONTimeStore) writeUnlocked(entries []TimeEntry) error {
|
||||
path := s.entriesPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(entries, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal time entries: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "time-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("rename temp to %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user