305 lines
6.8 KiB
Go
305 lines
6.8 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)
|
|
}
|
|
|
|
// 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
|
|
}
|