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,181 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// TimeEntriesStyles holds styles for the time-entry list.
|
||||
type TimeEntriesStyles struct {
|
||||
Title lipgloss.Style
|
||||
Row lipgloss.Style
|
||||
Selected lipgloss.Style
|
||||
Meta lipgloss.Style
|
||||
Total lipgloss.Style
|
||||
Hint lipgloss.Style
|
||||
Empty lipgloss.Style
|
||||
}
|
||||
|
||||
// TimeEntriesView lists time entries for one ticket.
|
||||
type TimeEntriesView struct {
|
||||
TicketTitle string
|
||||
Entries []ticket.TimeEntry
|
||||
Total time.Duration
|
||||
Cursor int
|
||||
Offset int
|
||||
Width int
|
||||
Height int
|
||||
Styles TimeEntriesStyles
|
||||
}
|
||||
|
||||
// NewTimeEntriesView builds a sorted (newest first) entry list.
|
||||
func NewTimeEntriesView(title string, entries []ticket.TimeEntry, width, height int, styles TimeEntriesStyles) TimeEntriesView {
|
||||
cp := append([]ticket.TimeEntry(nil), entries...)
|
||||
sort.Slice(cp, func(i, j int) bool {
|
||||
return cp[i].Start.After(cp[j].Start)
|
||||
})
|
||||
var total time.Duration
|
||||
for _, e := range cp {
|
||||
total += e.Duration
|
||||
}
|
||||
v := TimeEntriesView{
|
||||
TicketTitle: title,
|
||||
Entries: cp,
|
||||
Total: total,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Styles: styles,
|
||||
}
|
||||
v.ensureVisible()
|
||||
return v
|
||||
}
|
||||
|
||||
// SetSize updates viewport size.
|
||||
func (v *TimeEntriesView) SetSize(width, height int) {
|
||||
v.Width = width
|
||||
v.Height = height
|
||||
v.ensureVisible()
|
||||
}
|
||||
|
||||
// Selected returns the highlighted entry.
|
||||
func (v TimeEntriesView) Selected() (ticket.TimeEntry, bool) {
|
||||
if len(v.Entries) == 0 || v.Cursor < 0 || v.Cursor >= len(v.Entries) {
|
||||
return ticket.TimeEntry{}, false
|
||||
}
|
||||
return v.Entries[v.Cursor], true
|
||||
}
|
||||
|
||||
// Len returns the number of entries.
|
||||
func (v TimeEntriesView) Len() int { return len(v.Entries) }
|
||||
|
||||
// MoveDown moves selection down.
|
||||
func (v *TimeEntriesView) MoveDown() {
|
||||
if v.Cursor < len(v.Entries)-1 {
|
||||
v.Cursor++
|
||||
v.ensureVisible()
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves selection up.
|
||||
func (v *TimeEntriesView) MoveUp() {
|
||||
if v.Cursor > 0 {
|
||||
v.Cursor--
|
||||
v.ensureVisible()
|
||||
}
|
||||
}
|
||||
|
||||
// View renders the list.
|
||||
func (v TimeEntriesView) View() string {
|
||||
title := strings.TrimSpace(v.TicketTitle)
|
||||
if title == "" {
|
||||
title = "ticket"
|
||||
}
|
||||
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
|
||||
hint := v.Styles.Hint.Render("j/k navigate · e edit · x delete · esc back")
|
||||
|
||||
innerW := max(20, v.Width-2)
|
||||
var lines []string
|
||||
lines = append(lines, head, "")
|
||||
|
||||
visibleH := v.Height - 4
|
||||
if visibleH < 1 {
|
||||
visibleH = 1
|
||||
}
|
||||
|
||||
if len(v.Entries) == 0 {
|
||||
lines = append(lines, v.Styles.Empty.Render("No time entries for this ticket"))
|
||||
} else {
|
||||
end := v.Offset + visibleH
|
||||
if end > len(v.Entries) {
|
||||
end = len(v.Entries)
|
||||
}
|
||||
for i := v.Offset; i < end; i++ {
|
||||
line := formatEntryLine(v.Entries[i], innerW)
|
||||
if i == v.Cursor {
|
||||
line = v.Styles.Selected.Render(line)
|
||||
} else {
|
||||
line = v.Styles.Row.Render(line)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
for len(lines) < visibleH+2 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
totalLabel := "Total"
|
||||
totalDur := ticket.FormatDurationHuman(v.Total)
|
||||
totalLine := totalLabel + dotsPad(totalLabel, totalDur, innerW) + totalDur
|
||||
lines = append(lines, v.Styles.Total.Render(totalLine), hint)
|
||||
|
||||
body := strings.Join(lines, "\n")
|
||||
return lipgloss.NewStyle().Width(v.Width).Height(v.Height).Render(body)
|
||||
}
|
||||
|
||||
func formatEntryLine(e ticket.TimeEntry, width int) string {
|
||||
when := e.Start.Local().Format("2006-01-02 15:04")
|
||||
if e.Start.IsZero() {
|
||||
when = "—"
|
||||
}
|
||||
dur := ticket.FormatDurationHuman(e.Duration)
|
||||
src := e.Source
|
||||
if src == "" {
|
||||
src = "?"
|
||||
}
|
||||
left := when + " · " + dur + " · " + src
|
||||
return truncateOneLine(left, width)
|
||||
}
|
||||
|
||||
func (v *TimeEntriesView) ensureVisible() {
|
||||
if len(v.Entries) == 0 {
|
||||
v.Offset = 0
|
||||
v.Cursor = 0
|
||||
return
|
||||
}
|
||||
if v.Cursor < 0 {
|
||||
v.Cursor = 0
|
||||
}
|
||||
if v.Cursor >= len(v.Entries) {
|
||||
v.Cursor = len(v.Entries) - 1
|
||||
}
|
||||
visibleH := v.Height - 4
|
||||
if visibleH < 1 {
|
||||
visibleH = 1
|
||||
}
|
||||
if v.Cursor < v.Offset {
|
||||
v.Offset = v.Cursor
|
||||
}
|
||||
if v.Cursor >= v.Offset+visibleH {
|
||||
v.Offset = v.Cursor - visibleH + 1
|
||||
}
|
||||
if v.Offset < 0 {
|
||||
v.Offset = 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user