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) } // DeleteByProject removes all time entries for a project. func (s *JSONTimeStore) DeleteByProject(projectID string) error { s.mu.Lock() defer s.mu.Unlock() if projectID == "" { return fmt.Errorf("projectId is required") } entries, err := s.readUnlocked() if err != nil { return err } out := make([]TimeEntry, 0, len(entries)) for _, e := range entries { if e.ProjectID == projectID { continue } out = append(out, e) } 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 }