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:
Skruecken
2026-10-19 11:05:10 +02:00
co-authored by Cursor
parent 41feba11de
commit bfc1cbd4d9
23 changed files with 2149 additions and 94 deletions
+555 -33
View File
@@ -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 {
+34
View File
@@ -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
View File
@@ -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.
+25
View File
@@ -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()
+9
View File
@@ -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
}
+79
View File
@@ -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)
}
+153
View File
@@ -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
}
}
+21 -12
View File
@@ -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
}
+181
View File
@@ -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
}
}