Files
task-master/internal/ticket/report.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

158 lines
3.2 KiB
Go

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
}