Merge pull request #75 from LazyBachelor/LPM-140

LPM-140 The Sprinting Update
Adds sprint support across the web UI (board + issue detail), TUI kanban, CLI/REPL, and the Beads-backed storage layer.

Changes:

Introduces sprint entities in storage and exposes sprint operations via IssueService.
Updates web board to support backlog vs. sprint columns, sprint selection, and sprint creation routes.
Updates TUI kanban to show backlog + sprint columns and adds sprint selection/creation actions.
This commit is contained in:
Robin Olsen
2026-03-23 11:26:04 -07:00
committed by GitHub
parent 06404708ba
commit d900a5fcd3
33 changed files with 2378 additions and 771 deletions

View File

@@ -16,6 +16,8 @@ type KeyMap struct {
MoveIssueRight key.Binding
SubmitValidation key.Binding
AddComment key.Binding
SelectSprint key.Binding
CreateSprint key.Binding
}
var defaultKanbanKeyMap = KeyMap{
@@ -44,6 +46,14 @@ var defaultKanbanKeyMap = KeyMap{
key.WithKeys("c"),
key.WithHelp("c", "add comment"),
),
SelectSprint: key.NewBinding(
key.WithKeys("S"),
key.WithHelp("S", "select sprint"),
),
CreateSprint: key.NewBinding(
key.WithKeys("n"),
key.WithHelp("n", "new sprint"),
),
}
func (m *Model) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -117,6 +127,12 @@ func (m *Model) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
if selected := fl.SelectedItem(); selected.ID != "" {
cmd = m.startConfirmDelete(selected.ID, fl.Index())
}
case m.notInModalMsgWithKey(msg, m.keyMap.SelectSprint):
cmd = m.startSelectSprint()
case m.notInModalMsgWithKey(msg, m.keyMap.CreateSprint):
cmd = m.startCreateSprint()
}
return cmd

View File

@@ -20,6 +20,7 @@ type (
type Model struct {
header Header
backlogList IssueList
todoList IssueList
inProgList IssueList
blockedList IssueList
@@ -31,6 +32,9 @@ type Model struct {
width int
height int
currentSprintNum int
backlogNum int
// Modal and Focus management
modalManager *modal.Manager
focusManager *modal.FocusManager
@@ -61,15 +65,33 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui
deleteIndex: -1,
}
// Setup lists
m.issueDetail = components.NewIssueDetail()
m.helpBar = components.NewHelpBar(components.ViewKanban)
allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{})
todoIssues := components.StatusOnly(allIssues, models.StatusOpen)
inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress)
blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked)
doneIssues := components.StatusOnly(allIssues, models.StatusClosed)
ctx := context.Background()
backlogNum, _ := app.Issues.GetBacklogSprint(ctx)
m.backlogNum = backlogNum
backlogIssues, _ := app.Issues.GetIssuesNotInAnySprint(ctx)
m.backlogList = components.NewIssueListFromIssues(app, backlogIssues, 20, 10)
sprints, _ := app.Issues.GetSprints(ctx)
m.currentSprintNum = 0
for _, sprintNum := range sprints {
if sprintNum != backlogNum {
m.currentSprintNum = sprintNum
break
}
}
var sprintIssues []*models.Issue
if m.currentSprintNum > 0 {
sprintIssues, _ = app.Issues.GetIssuesBySprint(ctx, m.currentSprintNum)
}
todoIssues := components.StatusOnly(sprintIssues, models.StatusOpen)
inProgIssues := components.StatusOnly(sprintIssues, models.StatusInProgress)
blockedIssues := components.StatusOnly(sprintIssues, models.StatusBlocked)
doneIssues := components.StatusOnly(sprintIssues, models.StatusClosed)
m.todoList = components.NewIssueListFromIssues(app, todoIssues, 20, 10)
m.inProgList = components.NewIssueListFromIssues(app, inProgIssues, 20, 10)
@@ -77,16 +99,17 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui
m.doneList = components.NewIssueListFromIssues(app, doneIssues, 20, 10)
// Setup focus areas for kanban columns
m.focusManager.EnableArea(modal.FocusColumn0)
m.focusManager.EnableArea(modal.FocusColumn1)
m.focusManager.EnableArea(modal.FocusColumn2)
m.focusManager.EnableArea(modal.FocusColumn3)
m.focusManager.EnableArea(modal.FocusColumn4)
m.focusManager.SetCurrent(modal.FocusColumn1)
m.focusManager.SetCurrent(modal.FocusColumn0)
// Register modals
m.registerModals()
if selected := m.todoList.SelectedItem(); selected.ID != "" {
if selected := m.backlogList.SelectedItem(); selected.ID != "" {
m.setDetailIssueWithComments(selected.Issue)
}
@@ -138,7 +161,7 @@ func (m *Model) IsFocusedOnDetail() bool {
func (m *Model) ToggleFocus() {
if m.focusManager.IsDetailFocused() {
m.focusManager.SetCurrent(modal.FocusColumn1)
m.focusManager.SetCurrent(modal.FocusColumn0)
m.issueDetail.SetFocused(false)
} else {
m.focusManager.SetCurrent(modal.FocusDetail)
@@ -148,6 +171,8 @@ func (m *Model) ToggleFocus() {
func (m *Model) FocusedIssueList() *IssueList {
switch m.focusManager.Current() {
case modal.FocusColumn0:
return &m.backlogList
case modal.FocusColumn1:
return &m.todoList
case modal.FocusColumn2:
@@ -157,7 +182,7 @@ func (m *Model) FocusedIssueList() *IssueList {
case modal.FocusColumn4:
return &m.doneList
default:
return &m.todoList
return &m.backlogList
}
}
@@ -181,6 +206,8 @@ func (m *Model) setDetailIssueWithComments(issue models.Issue) {
func statusForColumn(col modal.FocusArea) models.Status {
switch col {
case modal.FocusColumn0:
return models.StatusOpen
case modal.FocusColumn1:
return models.StatusOpen
case modal.FocusColumn2:
@@ -204,25 +231,31 @@ func (m *Model) moveIssue(delta int) tea.Cmd {
currentCol := m.focusManager.Current()
var newCol modal.FocusArea
switch currentCol {
case modal.FocusColumn1:
case modal.FocusColumn0: // Backlog
if delta > 0 {
newCol = modal.FocusColumn2
return m.moveIssueToSprint(selected.ID, m.currentSprintNum)
}
case modal.FocusColumn2:
case modal.FocusColumn1: // To Do
if delta > 0 {
newCol = modal.FocusColumn3
newCol = modal.FocusColumn2 // To In Progress
} else if delta < 0 {
return m.moveIssueToBacklog(selected.ID)
}
case modal.FocusColumn2: // In Progress
if delta > 0 {
newCol = modal.FocusColumn3 // To Blocked
} else {
newCol = modal.FocusColumn1
newCol = modal.FocusColumn1 // To To Do
}
case modal.FocusColumn3:
case modal.FocusColumn3: // Blocked
if delta > 0 {
newCol = modal.FocusColumn4
newCol = modal.FocusColumn4 // To Done
} else {
newCol = modal.FocusColumn2
newCol = modal.FocusColumn2 // To In Progress
}
case modal.FocusColumn4:
case modal.FocusColumn4: // Done
if delta < 0 {
newCol = modal.FocusColumn3
newCol = modal.FocusColumn3 // To Blocked
}
}
@@ -233,3 +266,27 @@ func (m *Model) moveIssue(delta int) tea.Cmd {
newStatus := statusForColumn(newCol)
return msgs.UpdateIssueStatusCmd(m.app, selected.ID, string(newStatus))
}
// moveIssueToSprint moves an issue to a sprint
func (m *Model) moveIssueToSprint(issueID string, sprintNum int) tea.Cmd {
return func() tea.Msg {
err := m.app.Issues.AddIssueToSprint(context.Background(), issueID, sprintNum)
m.submitValidation()
if err != nil {
return m.refreshIssueListsAndSelectIssue(issueID)()
}
return m.refreshIssueListsAndSelectIssue(issueID)()
}
}
// moveIssueToBacklog removes an issue from the current sprint (moves it to backlog)
func (m *Model) moveIssueToBacklog(issueID string) tea.Cmd {
return func() tea.Msg {
err := m.app.Issues.RemoveIssueFromSprint(context.Background(), issueID, m.currentSprintNum)
m.submitValidation()
if err != nil {
return m.refreshIssueListsAndSelectIssue(issueID)()
}
return m.refreshIssueListsAndSelectIssue(issueID)()
}
}

View File

@@ -2,6 +2,7 @@ package kanban
import (
"context"
"fmt"
"strconv"
"charm.land/bubbles/v2/list"
@@ -14,42 +15,63 @@ import (
)
func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd {
allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{})
if err != nil {
return nil
}
ctx := context.Background()
todoIssues := components.StatusOnly(allIssues, models.StatusOpen)
inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress)
blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked)
doneIssues := components.StatusOnly(allIssues, models.StatusClosed)
backlogIssues, _ := m.app.Issues.GetIssuesNotInAnySprint(ctx)
backlogCmd := m.backlogList.SetIssues(backlogIssues)
sprintIssues, _ := m.app.Issues.GetIssuesBySprint(ctx, m.currentSprintNum)
todoIssues := components.StatusOnly(sprintIssues, models.StatusOpen)
inProgIssues := components.StatusOnly(sprintIssues, models.StatusInProgress)
blockedIssues := components.StatusOnly(sprintIssues, models.StatusBlocked)
doneIssues := components.StatusOnly(sprintIssues, models.StatusClosed)
todoCmd := m.todoList.SetIssues(todoIssues)
inProgCmd := m.inProgList.SetIssues(inProgIssues)
blockedCmd := m.blockedList.SetIssues(blockedIssues)
doneCmd := m.doneList.SetIssues(doneIssues)
// Find the issue to determine which column it belongs to
var targetStatus models.Status
for _, issue := range allIssues {
var inBacklog bool
for _, issue := range sprintIssues {
if issue == nil {
continue
}
if issue.ID == issueID {
m.setDetailIssueWithComments(*issue)
targetStatus = issue.Status
m.setDetailIssueWithComments(*issue)
break
}
}
// Check if issue is in backlog
if targetStatus == "" {
for _, issue := range backlogIssues {
if issue == nil {
continue
}
if issue.ID == issueID {
inBacklog = true
m.setDetailIssueWithComments(*issue)
break
}
}
}
switch targetStatus {
case models.StatusOpen:
switch {
case inBacklog:
m.focusManager.SetCurrent(modal.FocusColumn0)
case targetStatus == models.StatusOpen:
m.focusManager.SetCurrent(modal.FocusColumn1)
case models.StatusInProgress:
case targetStatus == models.StatusInProgress:
m.focusManager.SetCurrent(modal.FocusColumn2)
case models.StatusBlocked:
case targetStatus == models.StatusBlocked:
m.focusManager.SetCurrent(modal.FocusColumn3)
case models.StatusClosed:
case targetStatus == models.StatusClosed:
m.focusManager.SetCurrent(modal.FocusColumn4)
}
return tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg {
return tea.Sequence(backlogCmd, todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg {
return msgs.SelectIssueMsg{IssueID: issueID}
})
}
@@ -60,6 +82,27 @@ func (m *Model) refreshAndSubmit(issueID string) tea.Cmd {
return refreshCmd
}
// refreshAllIssueLists refreshes all issue lists without selecting a specific issue
func (m *Model) refreshAllIssueLists() tea.Cmd {
ctx := context.Background()
backlogIssues, _ := m.app.Issues.GetIssuesNotInAnySprint(ctx)
backlogCmd := m.backlogList.SetIssues(backlogIssues)
sprintIssues, _ := m.app.Issues.GetIssuesBySprint(ctx, m.currentSprintNum)
todoIssues := components.StatusOnly(sprintIssues, models.StatusOpen)
inProgIssues := components.StatusOnly(sprintIssues, models.StatusInProgress)
blockedIssues := components.StatusOnly(sprintIssues, models.StatusBlocked)
doneIssues := components.StatusOnly(sprintIssues, models.StatusClosed)
todoCmd := m.todoList.SetIssues(todoIssues)
inProgCmd := m.inProgList.SetIssues(inProgIssues)
blockedCmd := m.blockedList.SetIssues(blockedIssues)
doneCmd := m.doneList.SetIssues(doneIssues)
return tea.Batch(backlogCmd, todoCmd, inProgCmd, blockedCmd, doneCmd)
}
// Modal action handlers
func (m *Model) startConfirmExit() tea.Cmd {
@@ -121,6 +164,58 @@ func (m *Model) startChooseType(selected ListIssue) tea.Cmd {
return m.modalManager.ShowModal(modal.ModalSelectType)
}
func (m *Model) startSelectSprint() tea.Cmd {
sprints, err := m.app.Issues.GetSprints(context.Background())
if err != nil {
return nil
}
options := make([]modal.SelectOption, 0, len(sprints))
for _, sprintNum := range sprints {
if sprintNum == m.backlogNum {
continue
}
label := fmt.Sprintf("Sprint %d", sprintNum)
key := fmt.Sprintf("%d", sprintNum)
if sprintNum > 9 {
continue
}
options = append(options, modal.SelectOption{
Key: key,
Label: label,
Value: fmt.Sprintf("%d", sprintNum),
})
}
if len(options) == 0 {
return nil
}
sprintModal := modal.NewSelectModal(modal.SelectConfig{
ID: "dynamic-sprint-select",
Label: "Select sprint to view:",
Options: options,
CancelKey: "esc",
})
return m.modalManager.PushModal(sprintModal)
}
func (m *Model) startCreateSprint() tea.Cmd {
return tea.Sequence(
func() tea.Msg {
ctx := context.Background()
newSprintNum, err := m.app.Issues.AddSprint(ctx)
if err != nil {
return nil
}
m.currentSprintNum = newSprintNum
return nil
},
m.refreshAllIssueLists(),
)
}
func (m *Model) startEditAssignee(selected ListIssue) tea.Cmd {
m.currentIssueID = selected.ID
assigneeModal := m.modalManager.GetTextInputModal(modal.ModalEditAssignee)
@@ -210,6 +305,15 @@ func (m *Model) handleModalCompleted(msg modal.ModalCompletedMsg) tea.Cmd {
cmd := msgs.UpdateIssueTypeCmd(m.app, m.currentIssueID, issueType)
return func() tea.Msg { return cmd() }
}
case modal.ModalSelectSprint, "dynamic-sprint-select":
if r, ok := msg.Value.(modal.SelectResult); ok {
sprintNum, err := strconv.Atoi(r.SelectedValue)
if err != nil {
return nil
}
m.currentSprintNum = sprintNum
return m.refreshAllIssueLists()
}
case modal.ModalCloseReason:
if r, ok := msg.Value.(modal.TextAreaResult); ok && r.Value != "" {
cmd := msgs.CloseIssueCmd(m.app, m.currentIssueID, r.Value)
@@ -248,6 +352,7 @@ func (m *Model) handleModalCancelled(msg modal.ModalCancelledMsg) {
m.currentIssueID = ""
case modal.ModalSelectType:
m.currentIssueID = ""
case modal.ModalSelectSprint, "dynamic-sprint-select":
case modal.ModalCloseReason:
m.currentIssueID = ""
case modal.ModalAddComment:
@@ -322,6 +427,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.refreshIssueListsAndSelectIssue(msg.IssueID)
case msgs.SelectIssueMsg:
m.backlogList.SelectIssueID(msg.IssueID)
m.todoList.SelectIssueID(msg.IssueID)
m.inProgList.SelectIssueID(msg.IssueID)
m.blockedList.SelectIssueID(msg.IssueID)
@@ -333,21 +439,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Err != nil || msg.Issue == nil {
return m, nil
}
allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{})
if err != nil {
return m, nil
}
todoIssues := components.StatusOnly(allIssues, models.StatusOpen)
inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress)
blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked)
doneIssues := components.StatusOnly(allIssues, models.StatusClosed)
backlogIssues, _ := m.app.Issues.GetIssuesNotInAnySprint(context.Background())
backlogCmd := m.backlogList.SetIssues(backlogIssues)
sprintIssues, _ := m.app.Issues.GetIssuesBySprint(context.Background(), m.currentSprintNum)
todoIssues := components.StatusOnly(sprintIssues, models.StatusOpen)
inProgIssues := components.StatusOnly(sprintIssues, models.StatusInProgress)
blockedIssues := components.StatusOnly(sprintIssues, models.StatusBlocked)
doneIssues := components.StatusOnly(sprintIssues, models.StatusClosed)
todoCmd := m.todoList.SetIssues(todoIssues)
inProgCmd := m.inProgList.SetIssues(inProgIssues)
blockedCmd := m.blockedList.SetIssues(blockedIssues)
doneCmd := m.doneList.SetIssues(doneIssues)
allIssues := append(backlogIssues, sprintIssues...)
selectedIssue := msg.Issue
if selectedIssue.ID == "" {
for _, issue := range allIssues {
@@ -360,7 +468,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.setDetailIssueWithComments(*selectedIssue)
m.submitValidation()
return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg {
return m, tea.Sequence(backlogCmd, todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg {
return msgs.SelectIssueMsg{IssueID: selectedIssue.ID}
})

View File

@@ -1,11 +1,13 @@
package kanban
import (
"fmt"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/LazyBachelor/LazyPM/internal/style"
"github.com/LazyBachelor/LazyPM/pkg/tui/components"
"github.com/LazyBachelor/LazyPM/pkg/tui/modal"
"github.com/LazyBachelor/LazyPM/internal/style"
)
func (m *Model) View() tea.View {
@@ -16,6 +18,14 @@ func (m *Model) View() tea.View {
m.helpBar.SetWidth(m.width)
m.modalManager.SetSize(m.width, m.height)
var sprintName string
if m.currentSprintNum > 0 {
sprintName = fmt.Sprintf("Sprint %d", m.currentSprintNum)
} else {
sprintName = "No Sprint"
}
m.header = components.NewHeader(fmt.Sprintf("Kanban Board - %s", sprintName))
header := m.header.View(m.width)
headerHeight := m.header.Height()
@@ -24,49 +34,53 @@ func (m *Model) View() tea.View {
contentHeight := m.height - headerHeight - footerHeight
totalContentWidth := m.width - 1
colWidth := max(totalContentWidth/4, 20)
colWidth := max(totalContentWidth/5, 16)
// Calculate initial list height (half of content height, minimum 5 rows)
listHeight := contentHeight / 2
if listHeight < 5 {
listHeight = contentHeight
}
m.backlogList.SetSize(colWidth, listHeight-1)
m.todoList.SetSize(colWidth, listHeight-1)
m.inProgList.SetSize(colWidth, listHeight-1)
m.blockedList.SetSize(colWidth, listHeight-1)
m.doneList.SetSize(colWidth, listHeight-1)
// Only highlight the focused column's selected row
currentFocus := m.focusManager.Current()
m.backlogList.SetHighlightSelected(currentFocus == modal.FocusColumn0)
m.todoList.SetHighlightSelected(currentFocus == modal.FocusColumn1)
m.inProgList.SetHighlightSelected(currentFocus == modal.FocusColumn2)
m.blockedList.SetHighlightSelected(currentFocus == modal.FocusColumn3)
m.doneList.SetHighlightSelected(currentFocus == modal.FocusColumn4)
todoLabel := style.LabelStyle.Render("To Do")
inProgLabel := style.LabelStyle.Render("In Progress")
blockedLabel := style.LabelStyle.Render("Blocked")
doneLabel := style.LabelStyle.Render("Done")
backlogLabel := style.LabelStyle.Render("Backlog")
todoLabel := style.LabelStyle.Render(fmt.Sprintf("%s - To Do", sprintName))
inProgLabel := style.LabelStyle.Render(fmt.Sprintf("%s - In Progress", sprintName))
blockedLabel := style.LabelStyle.Render(fmt.Sprintf("%s - Blocked", sprintName))
doneLabel := style.LabelStyle.Render(fmt.Sprintf("%s - Done", sprintName))
highlight := lipgloss.NewStyle().Foreground(style.Primary).Bold(true)
switch currentFocus {
case modal.FocusColumn0:
backlogLabel = highlight.Render("Backlog ▶")
case modal.FocusColumn1:
todoLabel = highlight.Render("To Do ▶")
todoLabel = highlight.Render(fmt.Sprintf("%s - To Do ▶", sprintName))
case modal.FocusColumn2:
inProgLabel = highlight.Render("In Progress ▶")
inProgLabel = highlight.Render(fmt.Sprintf("%s - In Progress ▶", sprintName))
case modal.FocusColumn3:
blockedLabel = highlight.Render("Blocked ▶")
blockedLabel = highlight.Render(fmt.Sprintf("%s - Blocked ▶", sprintName))
case modal.FocusColumn4:
doneLabel = highlight.Render("Done ▶")
doneLabel = highlight.Render(fmt.Sprintf("%s - Done ▶", sprintName))
}
backlogCol := lipgloss.JoinVertical(lipgloss.Left, backlogLabel, m.backlogList.View())
todoCol := lipgloss.JoinVertical(lipgloss.Left, todoLabel, m.todoList.View())
inProgCol := lipgloss.JoinVertical(lipgloss.Left, inProgLabel, m.inProgList.View())
blockedCol := lipgloss.JoinVertical(lipgloss.Left, blockedLabel, m.blockedList.View())
doneCol := lipgloss.JoinVertical(lipgloss.Left, doneLabel, m.doneList.View())
board := lipgloss.JoinHorizontal(lipgloss.Left, todoCol, inProgCol, blockedCol, doneCol)
board := lipgloss.JoinHorizontal(lipgloss.Left, backlogCol, todoCol, inProgCol, blockedCol, doneCol)
boardHeight := lipgloss.Height(board)
detailHeight := max(contentHeight-boardHeight, 5)