Files
task-master/internal/ticket/jsontimestore.go
T
SkrueckenandCursor bfc1cbd4d9 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>
2026-10-19 11:05:10 +02:00

283 lines
6.4 KiB
Go

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
}