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>
This commit is contained in:
+555
-33
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
@@ -19,6 +20,9 @@ import (
|
||||
"task/internal/version"
|
||||
)
|
||||
|
||||
// timerTickMsg refreshes the header while a timer is running.
|
||||
type timerTickMsg time.Time
|
||||
|
||||
// editorFinishedMsg is sent after the external $EDITOR exits.
|
||||
type editorFinishedMsg struct {
|
||||
ticketID string // empty when create==true
|
||||
@@ -38,49 +42,66 @@ const (
|
||||
ModeStatus
|
||||
ModeBoard
|
||||
ModeConfirmDelete
|
||||
ModeTimeManual
|
||||
ModeTimeEntries
|
||||
ModeTimeEdit
|
||||
ModeReport
|
||||
ModeReportGroup
|
||||
)
|
||||
|
||||
// Model is the root Bubble Tea model.
|
||||
type Model struct {
|
||||
cfg *config.Config
|
||||
store ticket.Store
|
||||
projects project.Store
|
||||
exec command.Executor
|
||||
cfg *config.Config
|
||||
store ticket.Store
|
||||
timeStore ticket.TimeStore
|
||||
projects project.Store
|
||||
exec command.Executor
|
||||
|
||||
mode Mode
|
||||
tickets views.TicketList
|
||||
projectList list.Model
|
||||
statusMenu views.StatusMenu
|
||||
boardMenu views.BoardMenu
|
||||
boardFilter ticket.BoardFilter
|
||||
input textinput.Model
|
||||
width int
|
||||
height int
|
||||
status string
|
||||
statusIsError bool
|
||||
filter string
|
||||
keys KeyMap
|
||||
styles Styles
|
||||
version string
|
||||
allTickets []ticket.Ticket
|
||||
creatingProject bool
|
||||
pendingDeleteTitle string
|
||||
mode Mode
|
||||
tickets views.TicketList
|
||||
projectList list.Model
|
||||
statusMenu views.StatusMenu
|
||||
boardMenu views.BoardMenu
|
||||
boardFilter ticket.BoardFilter
|
||||
input textinput.Model
|
||||
report views.ReportView
|
||||
reportGroupBy ticket.GroupBy
|
||||
groupByMenu views.GroupByMenu
|
||||
timeEntries views.TimeEntriesView
|
||||
timeEntriesTicket string // ticket ID for ModeTimeEntries
|
||||
editingTimeEntry ticket.TimeEntry
|
||||
activeTimer *ticket.ActiveTimer
|
||||
spentByID map[string]time.Duration
|
||||
width int
|
||||
height int
|
||||
status string
|
||||
statusIsError bool
|
||||
filter string
|
||||
keys KeyMap
|
||||
styles Styles
|
||||
version string
|
||||
allTickets []ticket.Ticket
|
||||
creatingProject bool
|
||||
pendingDeleteTitle string
|
||||
}
|
||||
|
||||
// New creates the root UI model.
|
||||
func New(cfg *config.Config, store ticket.Store, projects project.Store, exec command.Executor) Model {
|
||||
func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor) Model {
|
||||
styles := DefaultStyles()
|
||||
if exec == nil {
|
||||
exec = command.NewHistory()
|
||||
}
|
||||
|
||||
m := Model{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
projects: projects,
|
||||
exec: exec,
|
||||
mode: ModeList,
|
||||
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
timeStore: timeStore,
|
||||
projects: projects,
|
||||
exec: exec,
|
||||
mode: ModeList,
|
||||
boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)),
|
||||
reportGroupBy: ticket.GroupByProject,
|
||||
spentByID: map[string]time.Duration{},
|
||||
tickets: views.TicketList{
|
||||
Styles: views.TicketListStyles{
|
||||
Card: styles.Card,
|
||||
@@ -90,21 +111,36 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, exec co
|
||||
StatusDONE: styles.StatusDone,
|
||||
Headline: styles.Headline,
|
||||
},
|
||||
SpentByID: map[string]time.Duration{},
|
||||
},
|
||||
input: views.NewCreateInput(),
|
||||
keys: DefaultKeyMap(),
|
||||
styles: styles,
|
||||
version: version.BuildVersion,
|
||||
}
|
||||
if timeStore != nil {
|
||||
if t, err := timeStore.LoadActiveTimer(); err == nil {
|
||||
m.activeTimer = t
|
||||
}
|
||||
}
|
||||
_ = m.reloadTickets()
|
||||
return m
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
if m.activeTimer != nil {
|
||||
return timerTickCmd()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func timerTickCmd() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return timerTickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Update implements tea.Model.
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
@@ -114,12 +150,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.resizeLists()
|
||||
return m, nil
|
||||
|
||||
case timerTickMsg:
|
||||
if m.activeTimer != nil {
|
||||
return m, timerTickCmd()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case editorFinishedMsg:
|
||||
return m.handleEditorFinished(msg)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.mode {
|
||||
case ModeCreate, ModeSearch:
|
||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
||||
return m.updateInput(msg)
|
||||
case ModeProjects:
|
||||
return m.updateProjects(msg)
|
||||
@@ -129,6 +171,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.updateBoard(msg)
|
||||
case ModeConfirmDelete:
|
||||
return m.updateConfirmDelete(msg)
|
||||
case ModeTimeEntries:
|
||||
return m.updateTimeEntries(msg)
|
||||
case ModeReport:
|
||||
return m.updateReport(msg)
|
||||
case ModeReportGroup:
|
||||
return m.updateReportGroup(msg)
|
||||
default:
|
||||
return m.updateList(msg)
|
||||
}
|
||||
@@ -138,7 +186,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m.mode {
|
||||
case ModeProjects:
|
||||
m.projectList, cmd = m.projectList.Update(msg)
|
||||
case ModeCreate, ModeSearch:
|
||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
@@ -173,6 +221,9 @@ func (m *Model) resizeLists() {
|
||||
m.input.Width = max(10, m.width-4)
|
||||
m.statusMenu.Width = m.width
|
||||
m.boardMenu.Width = m.width
|
||||
m.groupByMenu.Width = m.width
|
||||
m.report.SetSize(m.width, bodyH)
|
||||
m.timeEntries.SetSize(m.width, bodyH)
|
||||
}
|
||||
|
||||
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
@@ -276,6 +327,44 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.mode = ModeStatus
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Timer):
|
||||
m.clearStatus()
|
||||
cmd, err := m.toggleTimer()
|
||||
if err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case key.Matches(msg, m.keys.TimeManual):
|
||||
m.clearStatus()
|
||||
if _, ok := m.tickets.Selected(); !ok {
|
||||
m.setError(fmt.Errorf("no ticket selected"))
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeTimeManual
|
||||
m.input = views.NewTimeManualInput()
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Report):
|
||||
m.clearStatus()
|
||||
if err := m.openReport(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeReport
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.TimeEntries):
|
||||
m.clearStatus()
|
||||
if err := m.openTimeEntries(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeTimeEntries
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.tickets.MoveDown()
|
||||
return m, nil
|
||||
@@ -385,9 +474,12 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.creatingProject = false
|
||||
m.input.Blur()
|
||||
m.clearStatus()
|
||||
if wasProject {
|
||||
switch {
|
||||
case wasProject:
|
||||
m.mode = ModeProjects
|
||||
} else {
|
||||
case m.mode == ModeTimeEdit:
|
||||
m.mode = ModeTimeEntries
|
||||
default:
|
||||
m.mode = ModeList
|
||||
}
|
||||
return m, nil
|
||||
@@ -408,6 +500,26 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
value := strings.TrimSpace(m.input.Value())
|
||||
if m.mode == ModeTimeEdit {
|
||||
if err := m.saveEditedTime(value); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.input.Blur()
|
||||
m.mode = ModeTimeEntries
|
||||
m.setStatus("time updated")
|
||||
return m, nil
|
||||
}
|
||||
if m.mode == ModeTimeManual {
|
||||
if err := m.addManualTime(value); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeList
|
||||
m.input.Blur()
|
||||
m.setStatus("time logged")
|
||||
return m, nil
|
||||
}
|
||||
if m.mode == ModeCreate {
|
||||
if value == "" {
|
||||
m.setError(fmt.Errorf("name required"))
|
||||
@@ -455,6 +567,135 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) updateTimeEntries(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.timeEntriesTicket = ""
|
||||
m.mode = ModeList
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.timeEntries.MoveDown()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.timeEntries.MoveUp()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Delete), key.Matches(msg, m.keys.DeleteForce):
|
||||
m.clearStatus()
|
||||
if err := m.deleteSelectedTimeEntry(); err != nil {
|
||||
m.setError(err)
|
||||
} else {
|
||||
m.setStatus("time entry deleted")
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Edit):
|
||||
m.clearStatus()
|
||||
e, ok := m.timeEntries.Selected()
|
||||
if !ok {
|
||||
m.setError(fmt.Errorf("no entry selected"))
|
||||
return m, nil
|
||||
}
|
||||
m.editingTimeEntry = e
|
||||
m.mode = ModeTimeEdit
|
||||
m.input = views.NewTimeManualInput()
|
||||
m.input.SetValue(ticket.FormatDurationHuman(e.Duration))
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Undo):
|
||||
m.clearStatus()
|
||||
if err := m.undo(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
if err := m.openTimeEntriesFor(m.timeEntriesTicket); err != nil {
|
||||
m.setError(err)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Redo):
|
||||
m.clearStatus()
|
||||
if err := m.redo(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
if err := m.openTimeEntriesFor(m.timeEntriesTicket); err != nil {
|
||||
m.setError(err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateReport(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.mode = ModeList
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.report.MoveDown()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.report.MoveUp()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
||||
switch msg.Runes[0] {
|
||||
case 'g', 'G':
|
||||
m.groupByMenu = views.NewGroupByMenu(m.reportGroupBy, m.width, views.StatusMenuStyles{
|
||||
Box: m.styles.StatusMenuBox,
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
Normal: m.styles.StatusMenuNormal,
|
||||
})
|
||||
m.mode = ModeReportGroup
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateReportGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.mode = ModeReport
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
m.reportGroupBy = m.groupByMenu.Selected()
|
||||
if err := m.openReport(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeReport
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.groupByMenu.MoveDown()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.groupByMenu.MoveUp()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 {
|
||||
if m.groupByMenu.JumpToShortcut(msg.Runes[0]) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
@@ -730,10 +971,291 @@ func (m *Model) reloadTickets() error {
|
||||
return err
|
||||
}
|
||||
m.allTickets = tickets
|
||||
if err := m.reloadSpent(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.applyFilter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) reloadSpent() error {
|
||||
m.spentByID = map[string]time.Duration{}
|
||||
if m.timeStore == nil {
|
||||
m.tickets.SpentByID = m.spentByID
|
||||
return nil
|
||||
}
|
||||
for _, t := range m.allTickets {
|
||||
sum, err := m.timeStore.SumByTicket(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sum > 0 {
|
||||
m.spentByID[t.ID] = sum
|
||||
}
|
||||
}
|
||||
m.tickets.SpentByID = m.spentByID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) toggleTimer() (tea.Cmd, error) {
|
||||
if m.timeStore == nil {
|
||||
return nil, fmt.Errorf("time store not configured")
|
||||
}
|
||||
sel, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no ticket selected")
|
||||
}
|
||||
|
||||
if m.activeTimer == nil {
|
||||
t := &ticket.ActiveTimer{
|
||||
TicketID: sel.ID,
|
||||
ProjectID: sel.ProjectID,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := m.timeStore.SaveActiveTimer(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.activeTimer = t
|
||||
m.setStatus("timer started")
|
||||
return timerTickCmd(), nil
|
||||
}
|
||||
|
||||
// Stop current timer (same or other ticket).
|
||||
stoppedID := m.activeTimer.TicketID
|
||||
if err := m.stopActiveTimer(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.setStatus("timer stopped")
|
||||
|
||||
// If selection is a different ticket, start a new timer there.
|
||||
if sel.ID != stoppedID {
|
||||
t := &ticket.ActiveTimer{
|
||||
TicketID: sel.ID,
|
||||
ProjectID: sel.ProjectID,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := m.timeStore.SaveActiveTimer(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.activeTimer = t
|
||||
m.setStatus("timer switched")
|
||||
return timerTickCmd(), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *Model) stopActiveTimer() error {
|
||||
if m.activeTimer == nil || m.timeStore == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
start := m.activeTimer.StartedAt
|
||||
dur := now.Sub(start)
|
||||
if dur < time.Second {
|
||||
dur = time.Second
|
||||
}
|
||||
entry := ticket.TimeEntry{
|
||||
TicketID: m.activeTimer.TicketID,
|
||||
ProjectID: m.activeTimer.ProjectID,
|
||||
Start: start,
|
||||
End: now,
|
||||
Duration: dur,
|
||||
Source: ticket.SourceTimer,
|
||||
}
|
||||
if err := m.exec.Do(&command.AddTimeEntry{Store: m.timeStore, Entry: entry}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.timeStore.SaveActiveTimer(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
m.activeTimer = nil
|
||||
return m.reloadSpent()
|
||||
}
|
||||
|
||||
func (m *Model) addManualTime(raw string) error {
|
||||
if m.timeStore == nil {
|
||||
return fmt.Errorf("time store not configured")
|
||||
}
|
||||
sel, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
return fmt.Errorf("no ticket selected")
|
||||
}
|
||||
dur, err := ticket.ParseDurationFlexible(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
end := time.Now().UTC()
|
||||
start := end.Add(-dur)
|
||||
entry := ticket.TimeEntry{
|
||||
TicketID: sel.ID,
|
||||
ProjectID: sel.ProjectID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Duration: dur,
|
||||
Source: ticket.SourceManual,
|
||||
}
|
||||
if err := m.exec.Do(&command.AddTimeEntry{Store: m.timeStore, Entry: entry}); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.reloadSpent()
|
||||
}
|
||||
|
||||
func (m *Model) openTimeEntries() error {
|
||||
sel, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
return fmt.Errorf("no ticket selected")
|
||||
}
|
||||
return m.openTimeEntriesFor(sel.ID)
|
||||
}
|
||||
|
||||
func (m *Model) openTimeEntriesFor(ticketID string) error {
|
||||
if m.timeStore == nil {
|
||||
return fmt.Errorf("time store not configured")
|
||||
}
|
||||
if ticketID == "" {
|
||||
return fmt.Errorf("no ticket selected")
|
||||
}
|
||||
entries, err := m.timeStore.ListByTicket(ticketID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := ticketID
|
||||
if t, err := m.store.Get(ticketID); err == nil {
|
||||
title = strings.TrimSpace(t.Title)
|
||||
title = strings.TrimLeft(title, "#")
|
||||
title = strings.TrimSpace(title)
|
||||
}
|
||||
headerH, footerH := 1, 2
|
||||
bodyH := m.height - headerH - footerH
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
prevCursor := m.timeEntries.Cursor
|
||||
m.timeEntriesTicket = ticketID
|
||||
m.timeEntries = views.NewTimeEntriesView(title, entries, m.width, bodyH, views.TimeEntriesStyles{
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Row: m.styles.StatusMenuNormal,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
Meta: m.styles.HeaderMeta,
|
||||
Total: m.styles.Headline,
|
||||
Hint: m.styles.HelpDesc,
|
||||
Empty: m.styles.HelpDesc,
|
||||
})
|
||||
if prevCursor < m.timeEntries.Len() {
|
||||
m.timeEntries.Cursor = prevCursor
|
||||
m.timeEntries.SetSize(m.width, bodyH)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) deleteSelectedTimeEntry() error {
|
||||
e, ok := m.timeEntries.Selected()
|
||||
if !ok {
|
||||
return fmt.Errorf("no entry selected")
|
||||
}
|
||||
if err := m.exec.Do(&command.RemoveTimeEntry{Store: m.timeStore, Snapshot: e}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.reloadSpent(); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.openTimeEntriesFor(m.timeEntriesTicket)
|
||||
}
|
||||
|
||||
func (m *Model) saveEditedTime(raw string) error {
|
||||
if m.timeStore == nil {
|
||||
return fmt.Errorf("time store not configured")
|
||||
}
|
||||
dur, err := ticket.ParseDurationFlexible(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := m.editingTimeEntry
|
||||
after := before
|
||||
after.Duration = dur
|
||||
after.End = after.Start.Add(dur)
|
||||
if err := m.exec.Do(&command.UpdateTimeEntry{
|
||||
Store: m.timeStore,
|
||||
Before: before,
|
||||
After: after,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.reloadSpent(); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.openTimeEntriesFor(m.timeEntriesTicket)
|
||||
}
|
||||
|
||||
func (m *Model) openReport() error {
|
||||
if m.timeStore == nil {
|
||||
return fmt.Errorf("time store not configured")
|
||||
}
|
||||
entries, err := m.timeStore.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byID, err := m.allTicketsByID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := ticket.Aggregate(entries, byID, m.reportGroupBy)
|
||||
total := ticket.TotalDuration(entries)
|
||||
headerH, footerH := 1, 2
|
||||
bodyH := m.height - headerH - footerH
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
m.report = views.NewReportView(rows, total, m.reportGroupBy, m.width, bodyH, views.ReportStyles{
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Group: m.styles.StatusMenuNormal,
|
||||
Duration: m.styles.HeaderMeta,
|
||||
Total: m.styles.Headline,
|
||||
Hint: m.styles.HelpDesc,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) allTicketsByID() (map[string]ticket.Ticket, error) {
|
||||
out := map[string]ticket.Ticket{}
|
||||
projects, err := m.projects.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range projects {
|
||||
list, err := m.store.List(p.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range list {
|
||||
out[t.ID] = t
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m Model) timerHeaderLabel() string {
|
||||
if m.activeTimer == nil {
|
||||
return ""
|
||||
}
|
||||
title := m.activeTimer.TicketID
|
||||
if t, err := m.store.Get(m.activeTimer.TicketID); err == nil {
|
||||
title = strings.TrimSpace(t.Title)
|
||||
title = strings.TrimLeft(title, "#")
|
||||
title = strings.TrimSpace(title)
|
||||
if lipgloss.Width(title) > 24 {
|
||||
runes := []rune(title)
|
||||
for len(runes) > 0 && lipgloss.Width(string(runes)) > 23 {
|
||||
runes = runes[:len(runes)-1]
|
||||
}
|
||||
title = string(runes) + "…"
|
||||
}
|
||||
}
|
||||
elapsed := time.Since(m.activeTimer.StartedAt)
|
||||
return fmt.Sprintf("timer: %s %s", title, ticket.FormatDurationHuman(elapsed))
|
||||
}
|
||||
|
||||
func (m *Model) applyFilter() {
|
||||
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
|
||||
for _, t := range m.allTickets {
|
||||
|
||||
@@ -55,11 +55,45 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"y/enter", "confirm"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeTimeManual:
|
||||
return []helpEntry{
|
||||
{"enter", "save"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeTimeEntries:
|
||||
return []helpEntry{
|
||||
{"j/k", "navigate"},
|
||||
{"e", "edit"},
|
||||
{"x", "delete"},
|
||||
{"u/U", "undo/redo"},
|
||||
{"esc", "back"},
|
||||
}
|
||||
case ModeTimeEdit:
|
||||
return []helpEntry{
|
||||
{"enter", "save"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
case ModeReport:
|
||||
return []helpEntry{
|
||||
{"g", "group"},
|
||||
{"j/k", "scroll"},
|
||||
{"esc", "back"},
|
||||
}
|
||||
case ModeReportGroup:
|
||||
return []helpEntry{
|
||||
{"j/k", "select"},
|
||||
{"p/s/t/d", "jump"},
|
||||
{"enter", "apply"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
default:
|
||||
return []helpEntry{
|
||||
{"j/k", "navigate"},
|
||||
{"n", "new"},
|
||||
{"e", "edit"},
|
||||
{"t/T", "timer/log"},
|
||||
{"m", "entries"},
|
||||
{"r", "report"},
|
||||
{"u/U", "undo/redo"},
|
||||
{"/", "search"},
|
||||
{"p", "project"},
|
||||
|
||||
+39
-19
@@ -4,24 +4,28 @@ import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
// KeyMap defines vim-style bindings for the TUI.
|
||||
type KeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
New key.Binding
|
||||
Search key.Binding
|
||||
Project key.Binding
|
||||
Board key.Binding
|
||||
Status key.Binding
|
||||
Edit key.Binding
|
||||
EditInEditor key.Binding // ctrl+e — open $EDITOR while creating
|
||||
Delete key.Binding // x — confirm first
|
||||
DeleteForce key.Binding // X — immediate
|
||||
ConfirmYes key.Binding
|
||||
Toggle key.Binding // space — toggle multi-select
|
||||
Undo key.Binding
|
||||
Redo key.Binding
|
||||
Quit key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
New key.Binding
|
||||
Search key.Binding
|
||||
Project key.Binding
|
||||
Board key.Binding
|
||||
Status key.Binding
|
||||
Edit key.Binding
|
||||
EditInEditor key.Binding // ctrl+e — open $EDITOR while creating
|
||||
Delete key.Binding // x — confirm first
|
||||
DeleteForce key.Binding // X — immediate
|
||||
ConfirmYes key.Binding
|
||||
Toggle key.Binding // space — toggle multi-select
|
||||
Timer key.Binding // t — start/stop timer
|
||||
TimeManual key.Binding // T — manual duration
|
||||
TimeEntries key.Binding // m — manage time entries
|
||||
Report key.Binding // r — time report
|
||||
Undo key.Binding
|
||||
Redo key.Binding
|
||||
Quit key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns the default key bindings.
|
||||
@@ -79,6 +83,22 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys(" "),
|
||||
key.WithHelp("space", "toggle"),
|
||||
),
|
||||
Timer: key.NewBinding(
|
||||
key.WithKeys("t"),
|
||||
key.WithHelp("t", "timer"),
|
||||
),
|
||||
TimeManual: key.NewBinding(
|
||||
key.WithKeys("T"),
|
||||
key.WithHelp("T", "log time"),
|
||||
),
|
||||
TimeEntries: key.NewBinding(
|
||||
key.WithKeys("m"),
|
||||
key.WithHelp("m", "time entries"),
|
||||
),
|
||||
Report: key.NewBinding(
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("r", "report"),
|
||||
),
|
||||
Undo: key.NewBinding(
|
||||
key.WithKeys("u", "ctrl+z"),
|
||||
key.WithHelp("u", "undo"),
|
||||
@@ -104,7 +124,7 @@ func DefaultKeyMap() KeyMap {
|
||||
|
||||
// ShortHelp implements help.KeyMap.
|
||||
func (k KeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp implements help.KeyMap.
|
||||
|
||||
@@ -16,6 +16,9 @@ func (m Model) renderHeader() string {
|
||||
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
|
||||
|
||||
parts := []string{brand, project, board, count}
|
||||
if label := m.timerHeaderLabel(); label != "" {
|
||||
parts = append(parts, m.styles.FilterHint.Render(label))
|
||||
}
|
||||
if m.filter != "" {
|
||||
parts = append(parts, m.styles.FilterHint.Render(fmt.Sprintf("filter: %q", m.filter)))
|
||||
}
|
||||
@@ -51,6 +54,28 @@ func (m Model) renderBody(height int) string {
|
||||
content := prompt + "\n" + m.input.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||
|
||||
case ModeTimeManual:
|
||||
prompt := m.styles.InputPrompt.Render("Log time:")
|
||||
content := prompt + "\n" + m.input.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||
|
||||
case ModeTimeEdit:
|
||||
prompt := m.styles.InputPrompt.Render("Edit duration:")
|
||||
content := prompt + "\n" + m.input.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||
|
||||
case ModeTimeEntries:
|
||||
m.timeEntries.SetSize(m.width, height)
|
||||
return m.timeEntries.View()
|
||||
|
||||
case ModeReport:
|
||||
m.report.SetSize(m.width, height)
|
||||
return m.report.View()
|
||||
|
||||
case ModeReportGroup:
|
||||
menu := m.groupByMenu.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
||||
|
||||
case ModeProjects:
|
||||
m.projectList.SetSize(m.width, height)
|
||||
return m.projectList.View()
|
||||
|
||||
@@ -12,3 +12,12 @@ func NewCreateInput() textinput.Model {
|
||||
ti.Width = 60
|
||||
return ti
|
||||
}
|
||||
|
||||
// NewTimeManualInput returns a text input for manual duration entry.
|
||||
func NewTimeManualInput() textinput.Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Duration (1h30m, 90m, 1.5h)…"
|
||||
ti.CharLimit = 32
|
||||
ti.Width = 60
|
||||
return ti
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// GroupByMenu renders a report grouping picker.
|
||||
type GroupByMenu struct {
|
||||
Cursor int
|
||||
Width int
|
||||
Styles StatusMenuStyles
|
||||
}
|
||||
|
||||
// NewGroupByMenu creates a menu with the given grouping pre-selected.
|
||||
func NewGroupByMenu(current ticket.GroupBy, width int, styles StatusMenuStyles) GroupByMenu {
|
||||
return GroupByMenu{
|
||||
Cursor: current.Index(),
|
||||
Width: width,
|
||||
Styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves the selection down.
|
||||
func (m *GroupByMenu) MoveDown() {
|
||||
if m.Cursor < len(ticket.AllGroupBys)-1 {
|
||||
m.Cursor++
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves the selection up.
|
||||
func (m *GroupByMenu) MoveUp() {
|
||||
if m.Cursor > 0 {
|
||||
m.Cursor--
|
||||
}
|
||||
}
|
||||
|
||||
// JumpToShortcut moves the cursor to the option whose shortcut matches r.
|
||||
func (m *GroupByMenu) JumpToShortcut(r rune) bool {
|
||||
r = unicode.ToLower(r)
|
||||
for i, g := range ticket.AllGroupBys {
|
||||
if g.Shortcut() == r {
|
||||
m.Cursor = i
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Selected returns the currently highlighted grouping.
|
||||
func (m GroupByMenu) Selected() ticket.GroupBy {
|
||||
if m.Cursor < 0 || m.Cursor >= len(ticket.AllGroupBys) {
|
||||
return ticket.GroupByProject
|
||||
}
|
||||
return ticket.AllGroupBys[m.Cursor]
|
||||
}
|
||||
|
||||
// View renders the grouping menu.
|
||||
func (m GroupByMenu) View() string {
|
||||
var lines []string
|
||||
lines = append(lines, m.Styles.Title.Render("Group report by"))
|
||||
for i, g := range ticket.AllGroupBys {
|
||||
entry := fmt.Sprintf("%c %s", g.Shortcut(), g.Label())
|
||||
if i == m.Cursor {
|
||||
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
||||
} else {
|
||||
lines = append(lines, m.Styles.Normal.Render(" "+entry))
|
||||
}
|
||||
}
|
||||
body := strings.Join(lines, "\n")
|
||||
w := m.Width
|
||||
if w < 20 {
|
||||
w = 20
|
||||
}
|
||||
return m.Styles.Box.Width(min(40, w-2)).Render(body)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package views
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
@@ -25,12 +26,13 @@ type TicketListStyles struct {
|
||||
|
||||
// TicketList is a custom viewport list of ticket cards.
|
||||
type TicketList struct {
|
||||
Items []ticket.Ticket
|
||||
Cursor int
|
||||
Offset int
|
||||
Width int
|
||||
Height int
|
||||
Styles TicketListStyles
|
||||
Items []ticket.Ticket
|
||||
Cursor int
|
||||
Offset int
|
||||
Width int
|
||||
Height int
|
||||
Styles TicketListStyles
|
||||
SpentByID map[string]time.Duration // optional; shown on selected card
|
||||
}
|
||||
|
||||
// SetSize updates the viewport dimensions.
|
||||
@@ -124,12 +126,16 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
|
||||
|
||||
var content string
|
||||
if selected {
|
||||
// Expanded: headline + glamour-rendered description (fixed dark style).
|
||||
// Expanded: headline + optional spent + glamour description.
|
||||
content = headerLine
|
||||
if spent, ok := l.SpentByID[t.ID]; ok && spent > 0 {
|
||||
content += "\n" + lipgloss.NewStyle().Faint(true).Render(
|
||||
"spent: "+ticket.FormatDurationHuman(spent),
|
||||
)
|
||||
}
|
||||
desc := strings.TrimSpace(t.Description)
|
||||
if desc != "" {
|
||||
content = headerLine + "\n" + RenderMarkdown(desc, innerWidth)
|
||||
} else {
|
||||
content = headerLine
|
||||
content += "\n" + RenderMarkdown(desc, innerWidth)
|
||||
}
|
||||
} else {
|
||||
content = headerLine
|
||||
@@ -190,8 +196,11 @@ func (l *TicketList) estimateHeight(index int) int {
|
||||
if index < 0 || index >= len(l.Items) {
|
||||
return CollapsedCardHeight
|
||||
}
|
||||
if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" {
|
||||
return lipgloss.Height(l.renderCard(l.Items[index], true))
|
||||
if index == l.Cursor {
|
||||
t := l.Items[index]
|
||||
if strings.TrimSpace(t.Description) != "" || (l.SpentByID != nil && l.SpentByID[t.ID] > 0) {
|
||||
return lipgloss.Height(l.renderCard(t, true))
|
||||
}
|
||||
}
|
||||
return CollapsedCardHeight
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user