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) }