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,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
|
||||
}
|
||||
Reference in New Issue
Block a user