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 {