package ui import ( "fmt" "os" "strings" "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "task/internal/command" "task/internal/config" "task/internal/project" "task/internal/ticket" "task/internal/ui/views" "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 path string err error create bool // true: create ticket from edited file } // Mode is the active UI mode. type Mode int const ( ModeList Mode = iota ModeCreate ModeSearch ModeProjects ModeMoveTicket ModeStatus ModeBoard ModeConfirmDelete ModeTimeManual ModeTimeEntries ModeTimeEdit ModeReport ModeReportGroup ) // Model is the root Bubble Tea model. type Model struct { 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 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 pendingDeleteProject string // non-empty → confirm deletes a project, not a ticket } // New creates the root UI 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, 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, CardSelected: styles.CardSelected, StatusOPEN: styles.StatusTodo, StatusWIP: styles.StatusWIP, 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) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height 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, ModeTimeManual, ModeTimeEdit: return m.updateInput(msg) case ModeProjects: return m.updateProjects(msg) case ModeMoveTicket: return m.updateMoveTicket(msg) case ModeStatus: return m.updateStatus(msg) case ModeBoard: 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) } } var cmd tea.Cmd switch m.mode { case ModeProjects, ModeMoveTicket: m.projectList, cmd = m.projectList.Update(msg) case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit: m.input, cmd = m.input.Update(msg) } return m, cmd } // View implements tea.Model. func (m Model) View() string { if m.width == 0 { return "loading…" } header := m.renderHeader() footer := m.renderFooter() bodyH := m.height - lipgloss.Height(header) - lipgloss.Height(footer) if bodyH < 1 { bodyH = 1 } body := m.renderBody(bodyH) return lipgloss.JoinVertical(lipgloss.Left, header, body, footer) } func (m *Model) resizeLists() { headerH := 1 footerH := 1 bodyH := m.height - headerH - footerH if bodyH < 1 { bodyH = 1 } m.tickets.SetSize(m.width, bodyH) if m.mode == ModeProjects || m.mode == ModeMoveTicket { m.projectList.SetSize(m.width, bodyH) } 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) { switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit case key.Matches(msg, m.keys.New): m.clearStatus() m.creatingProject = false m.mode = ModeCreate m.input = views.NewCreateInput() m.input.Focus() return m, textinput.Blink case key.Matches(msg, m.keys.Search): m.clearStatus() m.creatingProject = false m.mode = ModeSearch m.input = views.NewSearchInput() m.input.SetValue(m.filter) m.input.Focus() return m, textinput.Blink case key.Matches(msg, m.keys.Project): m.clearStatus() if err := m.openProjects(); err != nil { m.setError(err) return m, nil } m.mode = ModeProjects return m, nil case key.Matches(msg, m.keys.MoveTicket): m.clearStatus() if _, ok := m.tickets.Selected(); !ok { m.setError(fmt.Errorf("no ticket selected")) return m, nil } if err := m.openProjects(); err != nil { m.setError(err) return m, nil } m.mode = ModeMoveTicket return m, nil case key.Matches(msg, m.keys.Board): m.clearStatus() m.boardMenu = views.NewBoardMenu(m.boardFilter, m.width, views.StatusMenuStyles{ Box: m.styles.StatusMenuBox, Title: m.styles.StatusMenuTitle, Selected: m.styles.StatusMenuSelected, Normal: m.styles.StatusMenuNormal, }) m.mode = ModeBoard return m, nil case key.Matches(msg, m.keys.DeleteForce): m.clearStatus() if err := m.deleteSelected(); err != nil { m.setError(err) } else { m.setStatus("deleted") } return m, nil case key.Matches(msg, m.keys.Delete): m.clearStatus() t, ok := m.tickets.Selected() if !ok { m.setError(fmt.Errorf("no ticket selected")) return m, nil } m.pendingDeleteTitle = strings.TrimSpace(t.Title) m.mode = ModeConfirmDelete return m, nil case key.Matches(msg, m.keys.Undo): m.clearStatus() if err := m.undo(); 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 case key.Matches(msg, m.keys.Edit): m.clearStatus() cmd, err := m.openEditorForSelected() if err != nil { m.setError(err) return m, nil } return m, cmd case key.Matches(msg, m.keys.Status): m.clearStatus() t, ok := m.tickets.Selected() if !ok { m.setError(fmt.Errorf("no ticket selected")) return m, nil } m.statusMenu = views.NewStatusMenu(t.Status, m.width, views.StatusMenuStyles{ Box: m.styles.StatusMenuBox, Title: m.styles.StatusMenuTitle, Selected: m.styles.StatusMenuSelected, Normal: m.styles.StatusMenuNormal, }) 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.ChecklistNext): m.clearStatus() m.tickets.MoveChecklistDown() return m, nil case key.Matches(msg, m.keys.ChecklistPrev): m.clearStatus() m.tickets.MoveChecklistUp() return m, nil case key.Matches(msg, m.keys.Toggle): m.clearStatus() if err := m.toggleSelectedCheckbox(); err != nil { m.setError(err) } return m, nil case key.Matches(msg, m.keys.Down): m.tickets.MoveDown() return m, nil case key.Matches(msg, m.keys.Up): m.tickets.MoveUp() return m, nil } return m, nil } func (m Model) updateStatus(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.Enter): if err := m.applyStatus(m.statusMenu.Selected()); err != nil { m.setError(err) return m, nil } m.mode = ModeList return m, nil case key.Matches(msg, m.keys.Down): m.statusMenu.MoveDown() return m, nil case key.Matches(msg, m.keys.Up): m.statusMenu.MoveUp() return m, nil } // Jump to option by first letter of its label (o/w/d). if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 { if m.statusMenu.JumpToShortcut(msg.Runes[0]) { return m, nil } } return m, nil } func (m Model) updateBoard(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.Enter): if err := m.applyBoardFilter(m.boardMenu.Selected()); err != nil { m.setError(err) return m, nil } m.mode = ModeList return m, nil case key.Matches(msg, m.keys.Toggle): m.boardMenu.Toggle() return m, nil case key.Matches(msg, m.keys.Down): m.boardMenu.MoveDown() return m, nil case key.Matches(msg, m.keys.Up): m.boardMenu.MoveUp() return m, nil } if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 { if m.boardMenu.JumpToShortcut(msg.Runes[0]) { return m, nil } } return m, nil } func (m Model) updateConfirmDelete(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Escape): wasProject := m.pendingDeleteProject != "" m.pendingDeleteTitle = "" m.pendingDeleteProject = "" m.clearStatus() if wasProject { m.mode = ModeProjects } else { m.mode = ModeList } return m, nil case key.Matches(msg, m.keys.Enter), key.Matches(msg, m.keys.ConfirmYes): if m.pendingDeleteProject != "" { id := m.pendingDeleteProject m.pendingDeleteTitle = "" m.pendingDeleteProject = "" if err := m.deleteProject(id); err != nil { m.setError(err) m.mode = ModeProjects return m, nil } m.mode = ModeList m.setStatus(fmt.Sprintf("deleted project %s", id)) return m, nil } m.pendingDeleteTitle = "" m.mode = ModeList if err := m.deleteSelected(); err != nil { m.setError(err) } else { m.setStatus("deleted") } return m, nil } return m, nil } func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Escape): wasProject := m.creatingProject m.creatingProject = false m.input.Blur() m.clearStatus() switch { case wasProject: m.mode = ModeProjects case m.mode == ModeTimeEdit: m.mode = ModeTimeEntries default: m.mode = ModeList } return m, nil case key.Matches(msg, m.keys.EditInEditor): if m.mode != ModeCreate || m.creatingProject { break } m.clearStatus() cmd, err := m.openEditorForNewTicket() if err != nil { m.setError(err) return m, nil } m.input.Blur() m.mode = ModeList return m, 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")) return m, nil } if m.creatingProject { id := sanitizeProjectID(value) if err := m.projects.Ensure(id); err != nil { m.setError(err) return m, nil } if err := m.switchProject(id); err != nil { m.setError(err) return m, nil } m.creatingProject = false m.mode = ModeList m.input.Blur() m.setStatus(fmt.Sprintf("created project %s", id)) return m, nil } if err := m.createTicket(value, ""); err != nil { m.setError(err) return m, nil } m.mode = ModeList m.input.Blur() m.setStatus("created") return m, nil } m.filter = value m.applyFilter() m.mode = ModeList m.input.Blur() if value == "" { m.setStatus("filter cleared") } else { m.setStatus(fmt.Sprintf("filter: %q", value)) } return m, nil } var cmd tea.Cmd m.input, cmd = m.input.Update(msg) 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) updateMoveTicket(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.Enter): item, ok := m.projectList.SelectedItem().(views.ProjectItem) if !ok { return m, nil } if err := m.moveSelectedToProject(item.Project.ID); err != nil { m.setError(err) return m, nil } m.mode = ModeList return m, nil } if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 { if views.JumpProjectByLetter(&m.projectList, msg.Runes[0]) { return m, nil } } var cmd tea.Cmd m.projectList, cmd = m.projectList.Update(msg) return m, cmd } func (m Model) updateProjects(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.New): m.clearStatus() m.creatingProject = true m.mode = ModeCreate m.input = views.NewCreateInput() m.input.Placeholder = "Project name…" m.input.Focus() return m, textinput.Blink case key.Matches(msg, m.keys.DeleteForce): m.clearStatus() item, ok := m.projectList.SelectedItem().(views.ProjectItem) if !ok { m.setError(fmt.Errorf("no project selected")) return m, nil } if err := m.deleteProject(item.Project.ID); err != nil { m.setError(err) return m, nil } m.mode = ModeList m.setStatus(fmt.Sprintf("deleted project %s", item.Project.ID)) return m, nil case key.Matches(msg, m.keys.Delete): m.clearStatus() item, ok := m.projectList.SelectedItem().(views.ProjectItem) if !ok { m.setError(fmt.Errorf("no project selected")) return m, nil } n, err := m.countTickets(item.Project.ID) if err != nil { m.setError(err) return m, nil } m.pendingDeleteProject = item.Project.ID m.pendingDeleteTitle = fmt.Sprintf("project %q (%d tickets)", item.Project.ID, n) m.mode = ModeConfirmDelete return m, nil case key.Matches(msg, m.keys.Enter): item, ok := m.projectList.SelectedItem().(views.ProjectItem) if !ok { return m, nil } if err := m.switchProject(item.Project.ID); err != nil { m.setError(err) return m, nil } m.mode = ModeList m.setStatus(fmt.Sprintf("switched to %s", item.Project.ID)) return m, nil } if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 { if views.JumpProjectByLetter(&m.projectList, msg.Runes[0]) { return m, nil } } var cmd tea.Cmd m.projectList, cmd = m.projectList.Update(msg) return m, cmd } func (m Model) openEditorForSelected() (tea.Cmd, error) { t, ok := m.tickets.Selected() if !ok { return nil, fmt.Errorf("no ticket selected") } path, err := ticket.WriteEditFile(t) if err != nil { return nil, err } c := ticket.EditorCmd(path) ticketID := t.ID return tea.ExecProcess(c, func(err error) tea.Msg { return editorFinishedMsg{ticketID: ticketID, path: path, err: err} }), nil } func (m Model) openEditorForNewTicket() (tea.Cmd, error) { title := strings.TrimSpace(m.input.Value()) if title == "" { title = "New ticket" } path, err := ticket.WriteEditFile(ticket.Ticket{Title: title}) if err != nil { return nil, err } c := ticket.EditorCmd(path) return tea.ExecProcess(c, func(err error) tea.Msg { return editorFinishedMsg{path: path, err: err, create: true} }), nil } func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) { defer func() { _ = os.Remove(msg.path) }() if msg.err != nil { m.setError(fmt.Errorf("editor: %w", msg.err)) return m, nil } data, err := os.ReadFile(msg.path) if err != nil { m.setError(fmt.Errorf("read edited file: %w", err)) return m, nil } title, description, err := ticket.ParseEditDocument(string(data)) if err != nil { m.setError(err) return m, nil } if msg.create { if err := m.createTicket(title, description); err != nil { m.setError(err) return m, nil } m.mode = ModeList m.setStatus("created") return m, nil } before, err := m.store.Get(msg.ticketID) if err != nil { m.setError(err) return m, nil } after := before after.Title = title after.Description = description if err := m.exec.Do(&command.UpdateTicket{ Store: m.store, Before: before, After: after, }); err != nil { m.setError(err) return m, nil } idx := m.tickets.CursorIndex() if err := m.reloadTickets(); err != nil { m.setError(err) return m, nil } if idx < m.tickets.Len() { m.tickets.Select(idx) } m.setStatus("saved") return m, nil } func (m *Model) createTicket(title, description string) error { cmd := &command.CreateTicket{ Store: m.store, Ticket: ticket.Ticket{ Title: title, Description: description, ProjectID: m.cfg.ActiveProject, Status: m.boardFilter.DefaultStatus(), }, } if err := m.exec.Do(cmd); err != nil { return err } return m.reloadTickets() } func (m *Model) deleteSelected() error { t, ok := m.tickets.Selected() if !ok { return fmt.Errorf("no ticket selected") } idx := m.tickets.CursorIndex() if err := m.exec.Do(&command.DeleteTicket{Store: m.store, Snapshot: t}); err != nil { return err } if err := m.reloadTickets(); err != nil { return err } if idx >= m.tickets.Len() && m.tickets.Len() > 0 { m.tickets.Select(m.tickets.Len() - 1) } else if idx < m.tickets.Len() { m.tickets.Select(idx) } return nil } func (m *Model) applyStatus(st ticket.Status) error { t, ok := m.tickets.Selected() if !ok { return fmt.Errorf("no ticket selected") } after := t after.Status = st if err := m.exec.Do(&command.UpdateTicket{ Store: m.store, Before: t, After: after, }); err != nil { return err } idx := m.tickets.CursorIndex() if err := m.reloadTickets(); err != nil { return err } if idx < m.tickets.Len() { m.tickets.Select(idx) } m.setStatus(fmt.Sprintf("status → %s", st.Label())) return nil } func (m *Model) toggleSelectedCheckbox() error { t, ok := m.tickets.Selected() if !ok { return fmt.Errorf("no ticket selected") } boxes := ticket.ListCheckboxes(t.Description) if len(boxes) == 0 { return fmt.Errorf("no checkboxes in description") } checkIdx := m.tickets.ChecklistCursor if checkIdx < 0 || checkIdx >= len(boxes) { checkIdx = 0 } newDesc, err := ticket.ToggleCheckbox(t.Description, checkIdx) if err != nil { return err } after := t after.Description = newDesc if err := m.exec.Do(&command.UpdateTicket{ Store: m.store, Before: t, After: after, }); err != nil { return err } ticketIdx := m.tickets.CursorIndex() if err := m.reloadTickets(); err != nil { return err } if ticketIdx < m.tickets.Len() { m.tickets.Select(ticketIdx) } m.tickets.SetChecklistCursor(checkIdx) state := "unchecked" if !boxes[checkIdx].Checked { state = "checked" } label := strings.TrimSpace(boxes[checkIdx].Text) if label == "" { label = fmt.Sprintf("#%d", checkIdx+1) } m.setStatus(fmt.Sprintf("%s %s", state, label)) return nil } func (m *Model) undo() error { name := m.exec.LastUndoName() if err := m.exec.Undo(); err != nil { return err } if err := m.reloadTickets(); err != nil { return err } if name != "" { m.setStatus("undo: " + name) } else { m.setStatus("undo") } return nil } func (m *Model) redo() error { name := m.exec.LastRedoName() if err := m.exec.Redo(); err != nil { return err } if err := m.reloadTickets(); err != nil { return err } if name != "" { m.setStatus("redo: " + name) } else { m.setStatus("redo") } return nil } func (m *Model) applyBoardFilter(f ticket.BoardFilter) error { m.boardFilter = ticket.NormalizeFilter(f) m.cfg.SetActiveBoardLists(m.cfg.ActiveProject, ticket.FormatBoardLists(m.boardFilter)) if err := config.Save(m.cfg); err != nil { return err } m.applyFilter() m.setStatus(fmt.Sprintf("list → %s", m.boardFilter.Label())) return nil } func (m *Model) switchProject(id string) error { if err := m.projects.Ensure(id); err != nil { return err } m.cfg.ActiveProject = id m.boardFilter = ticket.ParseBoardLists(m.cfg.ActiveBoardLists(id)) if err := config.Save(m.cfg); err != nil { return err } m.filter = "" return m.reloadTickets() } func (m *Model) moveSelectedToProject(toProjectID string) error { t, ok := m.tickets.Selected() if !ok { return fmt.Errorf("no ticket selected") } if toProjectID == t.ProjectID { m.setStatus("already there") return nil } if err := m.projects.Ensure(toProjectID); err != nil { return err } after := t after.ProjectID = toProjectID if err := m.exec.Do(&command.MoveTicket{ Store: m.store, TimeStore: m.timeStore, Before: t, After: after, }); err != nil { return err } if m.activeTimer != nil && m.activeTimer.TicketID == t.ID && m.timeStore != nil { m.activeTimer.ProjectID = toProjectID if err := m.timeStore.SaveActiveTimer(m.activeTimer); err != nil { return err } } if err := m.reloadTickets(); err != nil { return err } m.setStatus(fmt.Sprintf("moved → %s", toProjectID)) return nil } func (m *Model) countTickets(projectID string) (int, error) { tickets, err := m.store.List(projectID) if err != nil { return 0, err } return len(tickets), nil } func (m *Model) deleteProject(id string) error { if id == "" { return fmt.Errorf("no project selected") } all, err := m.projects.List() if err != nil { return err } if len(all) <= 1 { return fmt.Errorf("cannot delete the last project") } wasActive := m.cfg.ActiveProject == id // Clear running timer if it belongs to this project. if m.activeTimer != nil && m.activeTimer.ProjectID == id && m.timeStore != nil { if err := m.timeStore.SaveActiveTimer(nil); err != nil { return err } m.activeTimer = nil } if m.timeStore != nil { if err := m.timeStore.DeleteByProject(id); err != nil { return err } } if err := m.projects.Delete(id); err != nil { return err } m.cfg.ClearProjectLists(id) if wasActive { next := pickFallbackProject(all, id) m.cfg.ActiveProject = next m.boardFilter = ticket.ParseBoardLists(m.cfg.ActiveBoardLists(next)) m.filter = "" } if err := config.Save(m.cfg); err != nil { return err } if err := m.openProjects(); err != nil { return err } return m.reloadTickets() } func pickFallbackProject(all []project.Project, deletedID string) string { var rest []string for _, p := range all { if p.ID == deletedID { continue } rest = append(rest, p.ID) } for _, id := range rest { if id == config.DefaultProject { return id } } if len(rest) > 0 { return rest[0] } return config.DefaultProject } func (m *Model) openProjects() error { projects, err := m.projects.List() if err != nil { return err } if len(projects) == 0 { if err := m.projects.Ensure(m.cfg.ActiveProject); err != nil { return err } projects, err = m.projects.List() if err != nil { return err } } headerH, footerH := 1, 1 bodyH := m.height - headerH - footerH if bodyH < 1 { bodyH = 1 } m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH) return nil } func (m *Model) reloadTickets() error { tickets, err := m.store.List(m.cfg.ActiveProject) if err != nil { 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 { if m.boardFilter.Matches(t.Status) { filtered = append(filtered, t) } } filtered = views.FilterTickets(filtered, m.filter) cursor := m.tickets.CursorIndex() m.tickets.SetItems(filtered) if cursor < m.tickets.Len() { m.tickets.Select(cursor) } } func (m Model) ticketCount() int { return m.tickets.Len() } func (m *Model) setStatus(s string) { m.status = s m.statusIsError = false } func (m *Model) setError(err error) { m.status = err.Error() m.statusIsError = true } func (m *Model) clearStatus() { m.status = "" m.statusIsError = false } func sanitizeProjectID(name string) string { name = strings.TrimSpace(strings.ToLower(name)) var b strings.Builder for _, r := range name { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': b.WriteRune(r) case r == ' ': b.WriteRune('-') } } id := b.String() if id == "" { return "project" } return id } func max(a, b int) int { if a > b { return a } return b }