218 lines
4.7 KiB
Go
218 lines
4.7 KiB
Go
package ticket
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"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
|
|
}
|
|
|
|
// TimeFilter selects which time entries belong in a report.
|
|
// From/To are inclusive bounds on the entry's work instant (Start, else End).
|
|
// Zero times mean no bound on that side.
|
|
type TimeFilter struct {
|
|
From time.Time
|
|
To time.Time
|
|
TicketID string
|
|
}
|
|
|
|
// FilterEntries returns entries matching f. The input slice is not modified.
|
|
func FilterEntries(entries []TimeEntry, f TimeFilter) []TimeEntry {
|
|
if f.From.IsZero() && f.To.IsZero() && f.TicketID == "" {
|
|
out := make([]TimeEntry, len(entries))
|
|
copy(out, entries)
|
|
return out
|
|
}
|
|
out := make([]TimeEntry, 0, len(entries))
|
|
for _, e := range entries {
|
|
if f.TicketID != "" && e.TicketID != f.TicketID {
|
|
continue
|
|
}
|
|
when := e.Start
|
|
if when.IsZero() {
|
|
when = e.End
|
|
}
|
|
if !f.From.IsZero() && (when.IsZero() || when.Before(f.From)) {
|
|
continue
|
|
}
|
|
if !f.To.IsZero() && (when.IsZero() || when.After(f.To)) {
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ParseReportDay parses YYYY-MM-DD as local midnight. Empty returns zero time.
|
|
func ParseReportDay(s string) (time.Time, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return time.Time{}, nil
|
|
}
|
|
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid date %q", s)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// EndOfReportDay returns the last instant of the calendar day of t in local time.
|
|
func EndOfReportDay(t time.Time) time.Time {
|
|
if t.IsZero() {
|
|
return t
|
|
}
|
|
local := t.In(time.Local)
|
|
y, m, d := local.Date()
|
|
return time.Date(y, m, d, 23, 59, 59, 999999999, time.Local)
|
|
}
|