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