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>
154 lines
3.3 KiB
Go
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
|
|
}
|
|
}
|