Files
task-master/internal/ui/views/report.go
T
SkrueckenandCursor bfc1cbd4d9 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>
2026-10-19 11:05:10 +02:00

154 lines
3.3 KiB
Go

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