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