Files
task-master/internal/ui/views/timeentries.go
T
skrueckenandCursor 79b0821837 Allow editing time entry start and end in TUI and web.
Duration is derived from the range so timer sessions can be corrected after the fact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:59:40 +01:00

182 lines
4.0 KiB
Go

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 start/end · 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
}
}