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,59 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
reHourMin = regexp.MustCompile(`(?i)^(\d+)\s*h(?:ours?)?\s*(\d+)\s*m(?:ins?)?$`)
|
||||
reHourOnly = regexp.MustCompile(`(?i)^(\d+(?:\.\d+)?)\s*h(?:ours?)?$`)
|
||||
reMinOnly = regexp.MustCompile(`(?i)^(\d+)\s*m(?:ins?)?$`)
|
||||
reSecOnly = regexp.MustCompile(`(?i)^(\d+)\s*s(?:ecs?)?$`)
|
||||
reColon = regexp.MustCompile(`^(\d+):(\d{1,2})$`)
|
||||
)
|
||||
|
||||
// ParseDurationFlexible parses user durations like 1h30m, 90m, 1.5h, 1:30.
|
||||
func ParseDurationFlexible(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("duration required")
|
||||
}
|
||||
// Prefer Go's parser when it works (e.g. 1h30m, 90m).
|
||||
if d, err := time.ParseDuration(strings.ToLower(s)); err == nil && d > 0 {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
if m := reHourMin.FindStringSubmatch(s); m != nil {
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
min, _ := strconv.Atoi(m[2])
|
||||
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
|
||||
}
|
||||
if m := reHourOnly.FindStringSubmatch(s); m != nil {
|
||||
h, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q", s)
|
||||
}
|
||||
return time.Duration(h * float64(time.Hour)), nil
|
||||
}
|
||||
if m := reMinOnly.FindStringSubmatch(s); m != nil {
|
||||
min, _ := strconv.Atoi(m[1])
|
||||
return time.Duration(min) * time.Minute, nil
|
||||
}
|
||||
if m := reSecOnly.FindStringSubmatch(s); m != nil {
|
||||
sec, _ := strconv.Atoi(m[1])
|
||||
return time.Duration(sec) * time.Second, nil
|
||||
}
|
||||
if m := reColon.FindStringSubmatch(s); m != nil {
|
||||
h, _ := strconv.Atoi(m[1])
|
||||
min, _ := strconv.Atoi(m[2])
|
||||
if min >= 60 {
|
||||
return 0, fmt.Errorf("invalid minutes in %q", s)
|
||||
}
|
||||
return time.Duration(h)*time.Hour + time.Duration(min)*time.Minute, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid duration %q (try 1h30m, 90m, 1.5h)", s)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package ticket_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestParseDurationFlexible(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
}{
|
||||
{"1h30m", 90 * time.Minute},
|
||||
{"90m", 90 * time.Minute},
|
||||
{"1.5h", 90 * time.Minute},
|
||||
{"2h", 2 * time.Hour},
|
||||
{"45m", 45 * time.Minute},
|
||||
{"30s", 30 * time.Second},
|
||||
{"1:30", 90 * time.Minute},
|
||||
{" 1h ", time.Hour},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := ticket.ParseDurationFlexible(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", tc.in, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("%q: got %v want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationFlexibleRejects(t *testing.T) {
|
||||
for _, in := range []string{"", "abc", "1:99", "-5m"} {
|
||||
if _, err := ticket.ParseDurationFlexible(in); err == nil {
|
||||
t.Fatalf("%q: expected error", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDurationHuman(t *testing.T) {
|
||||
cases := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{2*time.Hour + 15*time.Minute, "2h15m"},
|
||||
{3 * time.Hour, "3h"},
|
||||
{45 * time.Minute, "45m"},
|
||||
{30 * time.Second, "30s"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ticket.FormatDurationHuman(tc.d); got != tc.want {
|
||||
t.Fatalf("%v: got %q want %q", tc.d, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package ticket_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestJSONTimeStoreRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONTimeStore(dir)
|
||||
|
||||
created, err := store.Create(ticket.TimeEntry{
|
||||
TicketID: "t1",
|
||||
ProjectID: "inbox",
|
||||
Start: time.Now().UTC().Add(-time.Hour),
|
||||
End: time.Now().UTC(),
|
||||
Duration: time.Hour,
|
||||
Source: ticket.SourceManual,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Fatal("expected id")
|
||||
}
|
||||
|
||||
list, err := store.List()
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(list))
|
||||
}
|
||||
sum, err := store.SumByTicket("t1")
|
||||
if err != nil || sum != time.Hour {
|
||||
t.Fatalf("sum: %v %v", err, sum)
|
||||
}
|
||||
|
||||
timer := &ticket.ActiveTimer{TicketID: "t1", ProjectID: "inbox", StartedAt: time.Now().UTC()}
|
||||
if err := store.SaveActiveTimer(timer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := store.LoadActiveTimer()
|
||||
if err != nil || loaded == nil || loaded.TicketID != "t1" {
|
||||
t.Fatalf("load timer: %v %+v", err, loaded)
|
||||
}
|
||||
if err := store.SaveActiveTimer(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded, err = store.LoadActiveTimer(); err != nil || loaded != nil {
|
||||
t.Fatalf("cleared timer: %v %+v", err, loaded)
|
||||
}
|
||||
|
||||
updated := created
|
||||
updated.Duration = 90 * time.Minute
|
||||
if err := store.Update(updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err = store.ListByTicket("t1")
|
||||
if err != nil || len(list) != 1 || list[0].Duration != 90*time.Minute {
|
||||
t.Fatalf("after update: %v %+v", err, list)
|
||||
}
|
||||
|
||||
if err := store.Delete(created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err = store.List()
|
||||
if err != nil || len(list) != 0 {
|
||||
t.Fatalf("after delete: %v len=%d path=%s", err, len(list), filepath.Join(dir, "time_entries.json"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GroupBy selects how Aggregate buckets time entries.
|
||||
type GroupBy string
|
||||
|
||||
const (
|
||||
GroupByProject GroupBy = "project"
|
||||
GroupByStatus GroupBy = "status"
|
||||
GroupByTicket GroupBy = "ticket"
|
||||
GroupByDay GroupBy = "day"
|
||||
)
|
||||
|
||||
// AllGroupBys is the ordered list of report grouping options.
|
||||
var AllGroupBys = []GroupBy{
|
||||
GroupByProject,
|
||||
GroupByStatus,
|
||||
GroupByTicket,
|
||||
GroupByDay,
|
||||
}
|
||||
|
||||
// Label returns a human-readable name for the grouping.
|
||||
func (g GroupBy) Label() string {
|
||||
switch g {
|
||||
case GroupByProject:
|
||||
return "Project"
|
||||
case GroupByStatus:
|
||||
return "Status"
|
||||
case GroupByTicket:
|
||||
return "Ticket"
|
||||
case GroupByDay:
|
||||
return "Day"
|
||||
default:
|
||||
return string(g)
|
||||
}
|
||||
}
|
||||
|
||||
// Shortcut returns the jump letter for the grouping picker.
|
||||
func (g GroupBy) Shortcut() rune {
|
||||
switch g {
|
||||
case GroupByProject:
|
||||
return 'p'
|
||||
case GroupByStatus:
|
||||
return 's'
|
||||
case GroupByTicket:
|
||||
return 't'
|
||||
case GroupByDay:
|
||||
return 'd'
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Index returns the position in AllGroupBys, or 0 if unknown.
|
||||
func (g GroupBy) Index() int {
|
||||
for i, x := range AllGroupBys {
|
||||
if x == g {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GroupRow is one aggregated bucket in a time report.
|
||||
type GroupRow struct {
|
||||
Key string
|
||||
Label string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Aggregate sums entry durations grouped by groupBy.
|
||||
// ticketsByID is used for status/ticket labels; missing tickets fall back to IDs.
|
||||
func Aggregate(entries []TimeEntry, ticketsByID map[string]Ticket, groupBy GroupBy) []GroupRow {
|
||||
sums := map[string]time.Duration{}
|
||||
labels := map[string]string{}
|
||||
|
||||
for _, e := range entries {
|
||||
key, label := groupKeyLabel(e, ticketsByID, groupBy)
|
||||
sums[key] += e.Duration
|
||||
if _, ok := labels[key]; !ok {
|
||||
labels[key] = label
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]GroupRow, 0, len(sums))
|
||||
for key, d := range sums {
|
||||
rows = append(rows, GroupRow{Key: key, Label: labels[key], Duration: d})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if groupBy == GroupByDay {
|
||||
return rows[i].Key > rows[j].Key // newest day first
|
||||
}
|
||||
if rows[i].Duration != rows[j].Duration {
|
||||
return rows[i].Duration > rows[j].Duration
|
||||
}
|
||||
return rows[i].Label < rows[j].Label
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
func groupKeyLabel(e TimeEntry, ticketsByID map[string]Ticket, groupBy GroupBy) (key, label string) {
|
||||
t, ok := ticketsByID[e.TicketID]
|
||||
switch groupBy {
|
||||
case GroupByProject:
|
||||
key = e.ProjectID
|
||||
if key == "" && ok {
|
||||
key = t.ProjectID
|
||||
}
|
||||
if key == "" {
|
||||
key = "(none)"
|
||||
}
|
||||
return key, key
|
||||
case GroupByStatus:
|
||||
if ok {
|
||||
key = string(t.Status)
|
||||
} else {
|
||||
key = "(unknown)"
|
||||
}
|
||||
return key, key
|
||||
case GroupByTicket:
|
||||
key = e.TicketID
|
||||
if ok && t.Title != "" {
|
||||
return key, t.Title
|
||||
}
|
||||
if key == "" {
|
||||
return "(none)", "(none)"
|
||||
}
|
||||
return key, key
|
||||
case GroupByDay:
|
||||
start := e.Start
|
||||
if start.IsZero() {
|
||||
start = e.End
|
||||
}
|
||||
if start.IsZero() {
|
||||
return "(unknown)", "(unknown)"
|
||||
}
|
||||
local := start.Local()
|
||||
key = local.Format("2006-01-02")
|
||||
return key, key
|
||||
default:
|
||||
return fmt.Sprintf("%v", groupBy), string(groupBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TotalDuration sums all entry durations.
|
||||
func TotalDuration(entries []TimeEntry) time.Duration {
|
||||
var sum time.Duration
|
||||
for _, e := range entries {
|
||||
sum += e.Duration
|
||||
}
|
||||
return sum
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package ticket_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestAggregateByProject(t *testing.T) {
|
||||
entries := []ticket.TimeEntry{
|
||||
{TicketID: "a", ProjectID: "p1", Duration: time.Hour},
|
||||
{TicketID: "b", ProjectID: "p1", Duration: 30 * time.Minute},
|
||||
{TicketID: "c", ProjectID: "p2", Duration: 15 * time.Minute},
|
||||
}
|
||||
rows := ticket.Aggregate(entries, nil, ticket.GroupByProject)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows=%d want 2", len(rows))
|
||||
}
|
||||
if rows[0].Key != "p1" || rows[0].Duration != 90*time.Minute {
|
||||
t.Fatalf("first row: %+v", rows[0])
|
||||
}
|
||||
if ticket.TotalDuration(entries) != 105*time.Minute {
|
||||
t.Fatalf("total=%v", ticket.TotalDuration(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateByStatusAndTicket(t *testing.T) {
|
||||
tickets := map[string]ticket.Ticket{
|
||||
"a": {ID: "a", Title: "Alpha", Status: ticket.StatusTodo},
|
||||
"b": {ID: "b", Title: "Beta", Status: ticket.StatusDone},
|
||||
}
|
||||
entries := []ticket.TimeEntry{
|
||||
{TicketID: "a", Duration: time.Hour},
|
||||
{TicketID: "b", Duration: 20 * time.Minute},
|
||||
{TicketID: "a", Duration: 10 * time.Minute},
|
||||
}
|
||||
|
||||
byStatus := ticket.Aggregate(entries, tickets, ticket.GroupByStatus)
|
||||
if len(byStatus) != 2 {
|
||||
t.Fatalf("status rows=%d", len(byStatus))
|
||||
}
|
||||
|
||||
byTicket := ticket.Aggregate(entries, tickets, ticket.GroupByTicket)
|
||||
if len(byTicket) != 2 {
|
||||
t.Fatalf("ticket rows=%d", len(byTicket))
|
||||
}
|
||||
if byTicket[0].Label != "Alpha" || byTicket[0].Duration != 70*time.Minute {
|
||||
t.Fatalf("top ticket: %+v", byTicket[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateByDay(t *testing.T) {
|
||||
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
|
||||
d2 := time.Date(2026, 10, 19, 11, 0, 0, 0, time.Local)
|
||||
entries := []ticket.TimeEntry{
|
||||
{Start: d1, Duration: time.Hour},
|
||||
{Start: d2, Duration: 30 * time.Minute},
|
||||
{Start: d1, Duration: 15 * time.Minute},
|
||||
}
|
||||
rows := ticket.Aggregate(entries, nil, ticket.GroupByDay)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("day rows=%d", len(rows))
|
||||
}
|
||||
// Newest day first.
|
||||
if rows[0].Key != "2026-10-19" {
|
||||
t.Fatalf("first day key=%q", rows[0].Key)
|
||||
}
|
||||
if rows[1].Duration != 75*time.Minute {
|
||||
t.Fatalf("older day dur=%v", rows[1].Duration)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceTimer = "timer"
|
||||
SourceManual = "manual"
|
||||
)
|
||||
|
||||
// TimeEntry is a recorded block of work on a ticket.
|
||||
type TimeEntry struct {
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON encodes Duration as nanoseconds for stable JSON.
|
||||
func (e TimeEntry) MarshalJSON() ([]byte, error) {
|
||||
type alias struct {
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration int64 `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
return json.Marshal(alias{
|
||||
ID: e.ID,
|
||||
TicketID: e.TicketID,
|
||||
ProjectID: e.ProjectID,
|
||||
Start: e.Start,
|
||||
End: e.End,
|
||||
Duration: int64(e.Duration),
|
||||
Source: e.Source,
|
||||
Note: e.Note,
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes Duration from nanoseconds.
|
||||
func (e *TimeEntry) UnmarshalJSON(data []byte) error {
|
||||
type alias struct {
|
||||
ID string `json:"id"`
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Duration int64 `json:"duration"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
e.ID = a.ID
|
||||
e.TicketID = a.TicketID
|
||||
e.ProjectID = a.ProjectID
|
||||
e.Start = a.Start
|
||||
e.End = a.End
|
||||
e.Duration = time.Duration(a.Duration)
|
||||
e.Source = a.Source
|
||||
e.Note = a.Note
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActiveTimer is a running stopwatch persisted separately until stop.
|
||||
type ActiveTimer struct {
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
}
|
||||
|
||||
// FormatDurationHuman returns a compact duration like 2h15m or 45m.
|
||||
func FormatDurationHuman(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
d = d.Round(time.Second)
|
||||
h := d / time.Hour
|
||||
m := (d % time.Hour) / time.Minute
|
||||
s := (d % time.Minute) / time.Second
|
||||
switch {
|
||||
case h > 0 && m > 0:
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%dh", h)
|
||||
case m > 0 && s > 0 && m < 5:
|
||||
return fmt.Sprintf("%dm%ds", m, s)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%dm", m)
|
||||
default:
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ticket
|
||||
|
||||
import "time"
|
||||
|
||||
// TimeStore persists time entries and the optional active timer.
|
||||
type TimeStore interface {
|
||||
List() ([]TimeEntry, error)
|
||||
ListByTicket(ticketID string) ([]TimeEntry, error)
|
||||
Create(e TimeEntry) (TimeEntry, error)
|
||||
Update(e TimeEntry) error
|
||||
Delete(id string) error
|
||||
SumByTicket(ticketID string) (time.Duration, error)
|
||||
|
||||
LoadActiveTimer() (*ActiveTimer, error)
|
||||
SaveActiveTimer(t *ActiveTimer) error // nil clears
|
||||
}
|
||||
Reference in New Issue
Block a user