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