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:
Skruecken
2026-10-19 11:05:10 +02:00
co-authored by Cursor
parent 41feba11de
commit bfc1cbd4d9
23 changed files with 2149 additions and 94 deletions
+9
View File
@@ -12,3 +12,12 @@ func NewCreateInput() textinput.Model {
ti.Width = 60
return ti
}
// NewTimeManualInput returns a text input for manual duration entry.
func NewTimeManualInput() textinput.Model {
ti := textinput.New()
ti.Placeholder = "Duration (1h30m, 90m, 1.5h)…"
ti.CharLimit = 32
ti.Width = 60
return ti
}
+79
View File
@@ -0,0 +1,79 @@
package views
import (
"fmt"
"strings"
"unicode"
"task/internal/ticket"
)
// GroupByMenu renders a report grouping picker.
type GroupByMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
}
// NewGroupByMenu creates a menu with the given grouping pre-selected.
func NewGroupByMenu(current ticket.GroupBy, width int, styles StatusMenuStyles) GroupByMenu {
return GroupByMenu{
Cursor: current.Index(),
Width: width,
Styles: styles,
}
}
// MoveDown moves the selection down.
func (m *GroupByMenu) MoveDown() {
if m.Cursor < len(ticket.AllGroupBys)-1 {
m.Cursor++
}
}
// MoveUp moves the selection up.
func (m *GroupByMenu) MoveUp() {
if m.Cursor > 0 {
m.Cursor--
}
}
// JumpToShortcut moves the cursor to the option whose shortcut matches r.
func (m *GroupByMenu) JumpToShortcut(r rune) bool {
r = unicode.ToLower(r)
for i, g := range ticket.AllGroupBys {
if g.Shortcut() == r {
m.Cursor = i
return true
}
}
return false
}
// Selected returns the currently highlighted grouping.
func (m GroupByMenu) Selected() ticket.GroupBy {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllGroupBys) {
return ticket.GroupByProject
}
return ticket.AllGroupBys[m.Cursor]
}
// View renders the grouping menu.
func (m GroupByMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Group report by"))
for i, g := range ticket.AllGroupBys {
entry := fmt.Sprintf("%c %s", g.Shortcut(), g.Label())
if i == m.Cursor {
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
} else {
lines = append(lines, m.Styles.Normal.Render(" "+entry))
}
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(40, w-2)).Render(body)
}
+153
View File
@@ -0,0 +1,153 @@
package views
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// ReportStyles holds styles for the report dialog.
type ReportStyles struct {
Title lipgloss.Style
Group lipgloss.Style
Duration lipgloss.Style
Total lipgloss.Style
Hint lipgloss.Style
Selected lipgloss.Style
}
// ReportView renders aggregated time entries.
type ReportView struct {
Rows []ticket.GroupRow
Total time.Duration
GroupBy ticket.GroupBy
Offset int
Cursor int
Width int
Height int
Styles ReportStyles
}
// NewReportView builds a report view from aggregated rows.
func NewReportView(rows []ticket.GroupRow, total time.Duration, groupBy ticket.GroupBy, width, height int, styles ReportStyles) ReportView {
return ReportView{
Rows: rows,
Total: total,
GroupBy: groupBy,
Width: width,
Height: height,
Styles: styles,
}
}
// SetSize updates viewport size.
func (r *ReportView) SetSize(width, height int) {
r.Width = width
r.Height = height
r.ensureVisible()
}
// MoveDown moves selection down.
func (r *ReportView) MoveDown() {
if r.Cursor < len(r.Rows)-1 {
r.Cursor++
r.ensureVisible()
}
}
// MoveUp moves selection up.
func (r *ReportView) MoveUp() {
if r.Cursor > 0 {
r.Cursor--
r.ensureVisible()
}
}
// View renders the report.
func (r ReportView) View() string {
title := r.Styles.Title.Render(fmt.Sprintf("Time report · by %s", r.GroupBy.Label()))
hint := r.Styles.Hint.Render("g group · j/k scroll · esc back")
innerW := max(20, r.Width-2)
var lines []string
lines = append(lines, title, "")
visibleH := r.Height - 4 // title, blank, total, hint
if visibleH < 1 {
visibleH = 1
}
if len(r.Rows) == 0 {
lines = append(lines, r.Styles.Hint.Render("No time entries yet"))
} else {
end := r.Offset + visibleH
if end > len(r.Rows) {
end = len(r.Rows)
}
for i := r.Offset; i < end; i++ {
row := r.Rows[i]
label := truncateOneLine(row.Label, max(8, innerW-14))
dur := ticket.FormatDurationHuman(row.Duration)
dots := dotsPad(label, dur, innerW)
line := label + dots + dur
if i == r.Cursor {
line = r.Styles.Selected.Render(line)
} else {
line = r.Styles.Group.Render(label) + dots + r.Styles.Duration.Render(dur)
}
lines = append(lines, line)
}
}
for len(lines) < visibleH+2 {
lines = append(lines, "")
}
totalLabel := "Total"
totalDur := ticket.FormatDurationHuman(r.Total)
totalLine := totalLabel + dotsPad(totalLabel, totalDur, innerW) + totalDur
lines = append(lines, r.Styles.Total.Render(totalLine), hint)
body := strings.Join(lines, "\n")
return lipgloss.NewStyle().Width(r.Width).Height(r.Height).Render(body)
}
func dotsPad(left, right string, width int) string {
used := lipgloss.Width(left) + lipgloss.Width(right)
n := width - used
if n < 2 {
return " "
}
return " " + strings.Repeat("·", n-2) + " "
}
func (r *ReportView) ensureVisible() {
if len(r.Rows) == 0 {
r.Offset = 0
r.Cursor = 0
return
}
if r.Cursor < 0 {
r.Cursor = 0
}
if r.Cursor >= len(r.Rows) {
r.Cursor = len(r.Rows) - 1
}
visibleH := r.Height - 4
if visibleH < 1 {
visibleH = 1
}
if r.Cursor < r.Offset {
r.Offset = r.Cursor
}
if r.Cursor >= r.Offset+visibleH {
r.Offset = r.Cursor - visibleH + 1
}
if r.Offset < 0 {
r.Offset = 0
}
}
+21 -12
View File
@@ -3,6 +3,7 @@ package views
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
@@ -25,12 +26,13 @@ type TicketListStyles struct {
// TicketList is a custom viewport list of ticket cards.
type TicketList struct {
Items []ticket.Ticket
Cursor int
Offset int
Width int
Height int
Styles TicketListStyles
Items []ticket.Ticket
Cursor int
Offset int
Width int
Height int
Styles TicketListStyles
SpentByID map[string]time.Duration // optional; shown on selected card
}
// SetSize updates the viewport dimensions.
@@ -124,12 +126,16 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
var content string
if selected {
// Expanded: headline + glamour-rendered description (fixed dark style).
// Expanded: headline + optional spent + glamour description.
content = headerLine
if spent, ok := l.SpentByID[t.ID]; ok && spent > 0 {
content += "\n" + lipgloss.NewStyle().Faint(true).Render(
"spent: "+ticket.FormatDurationHuman(spent),
)
}
desc := strings.TrimSpace(t.Description)
if desc != "" {
content = headerLine + "\n" + RenderMarkdown(desc, innerWidth)
} else {
content = headerLine
content += "\n" + RenderMarkdown(desc, innerWidth)
}
} else {
content = headerLine
@@ -190,8 +196,11 @@ func (l *TicketList) estimateHeight(index int) int {
if index < 0 || index >= len(l.Items) {
return CollapsedCardHeight
}
if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" {
return lipgloss.Height(l.renderCard(l.Items[index], true))
if index == l.Cursor {
t := l.Items[index]
if strings.TrimSpace(t.Description) != "" || (l.SpentByID != nil && l.SpentByID[t.ID] > 0) {
return lipgloss.Height(l.renderCard(t, true))
}
}
return CollapsedCardHeight
}
+181
View File
@@ -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
}
}