Files
LazyPM/pkg/tui/views/kanban/model.go
Robin Olsen d900a5fcd3 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.
2026-03-23 19:26:04 +01:00

293 lines
7.6 KiB
Go

package kanban
import (
"context"
tea "charm.land/bubbletea/v2"
"github.com/LazyBachelor/LazyPM/internal/app"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/pkg/tui/components"
"github.com/LazyBachelor/LazyPM/pkg/tui/modal"
"github.com/LazyBachelor/LazyPM/pkg/tui/msgs"
)
type (
Header = components.Header
IssueList = components.IssueList
IssueDetail = components.IssueDetail
ListIssue = components.ListIssue
)
type Model struct {
header Header
backlogList IssueList
todoList IssueList
inProgList IssueList
blockedList IssueList
doneList IssueList
issueDetail IssueDetail
helpBar components.HelpBar
keyMap KeyMap
app *app.App
width int
height int
currentSprintNum int
backlogNum int
// Modal and Focus management
modalManager *modal.Manager
focusManager *modal.FocusManager
// Current issue being operated on
currentIssueID string
deleteIndex int
feedbackChan chan models.ValidationFeedback
quitChan chan bool
currentFeedback models.ValidationFeedback
showComplete bool
submitChan chan<- struct{}
}
func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model {
m := &Model{
header: components.NewHeader("Kanban Board"),
keyMap: defaultKanbanKeyMap,
app: app,
width: 80,
height: 24,
feedbackChan: feedbackChan,
quitChan: quitChan,
submitChan: submitChan,
modalManager: modal.NewManager(),
focusManager: modal.NewFocusManager(),
deleteIndex: -1,
}
m.issueDetail = components.NewIssueDetail()
m.helpBar = components.NewHelpBar(components.ViewKanban)
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)
m.blockedList = components.NewIssueListFromIssues(app, blockedIssues, 20, 10)
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.FocusColumn0)
// Register modals
m.registerModals()
if selected := m.backlogList.SelectedItem(); selected.ID != "" {
m.setDetailIssueWithComments(selected.Issue)
}
return m
}
func (m *Model) Init() tea.Cmd {
if m.submitChan != nil {
m.submitChan <- struct{}{}
m.logAction("tui submitted validation")
}
return components.ListenForValidation(m.feedbackChan)
}
func (m *Model) registerModals() {
modal.RegisterCommonModals(m.modalManager)
}
func (m *Model) logAction(action string) {
if m.app != nil {
m.app.LogAction(models.EncodeActionEvent(models.ActionEvent{
Source: "tui",
Action: action,
}))
}
}
func (m *Model) submitValidation() {
if m.submitChan != nil {
select {
case m.submitChan <- struct{}{}:
m.logAction("tui submitted validation")
default:
}
}
}
func (m *Model) IsInModal() bool {
return m.modalManager.IsModalActive()
}
func (m *Model) IsFocusedOnList() bool {
return m.focusManager.IsListFocused()
}
func (m *Model) IsFocusedOnDetail() bool {
return m.focusManager.IsDetailFocused()
}
func (m *Model) ToggleFocus() {
if m.focusManager.IsDetailFocused() {
m.focusManager.SetCurrent(modal.FocusColumn0)
m.issueDetail.SetFocused(false)
} else {
m.focusManager.SetCurrent(modal.FocusDetail)
m.issueDetail.SetFocused(true)
}
}
func (m *Model) FocusedIssueList() *IssueList {
switch m.focusManager.Current() {
case modal.FocusColumn0:
return &m.backlogList
case modal.FocusColumn1:
return &m.todoList
case modal.FocusColumn2:
return &m.inProgList
case modal.FocusColumn3:
return &m.blockedList
case modal.FocusColumn4:
return &m.doneList
default:
return &m.backlogList
}
}
func (m *Model) updateDetailFromSelection() {
selected := m.FocusedIssueList().SelectedItem()
if selected.ID != "" {
m.setDetailIssueWithComments(selected.Issue)
}
}
// setDetailIssueWithComments sets the issue in the detail pane and loads its comments.
func (m *Model) setDetailIssueWithComments(issue models.Issue) {
m.issueDetail.SetIssue(issue)
if issue.ID == "" {
m.issueDetail.SetComments(nil)
return
}
comments, _ := m.app.Issues.GetIssueComments(context.Background(), issue.ID)
m.issueDetail.SetComments(comments)
}
func statusForColumn(col modal.FocusArea) models.Status {
switch col {
case modal.FocusColumn0:
return models.StatusOpen
case modal.FocusColumn1:
return models.StatusOpen
case modal.FocusColumn2:
return models.StatusInProgress
case modal.FocusColumn3:
return models.StatusBlocked
case modal.FocusColumn4:
return models.StatusClosed
default:
return models.StatusOpen
}
}
func (m *Model) moveIssue(delta int) tea.Cmd {
fl := m.FocusedIssueList()
selected := fl.SelectedItem()
if selected.ID == "" {
return nil
}
currentCol := m.focusManager.Current()
var newCol modal.FocusArea
switch currentCol {
case modal.FocusColumn0: // Backlog
if delta > 0 {
return m.moveIssueToSprint(selected.ID, m.currentSprintNum)
}
case modal.FocusColumn1: // To Do
if delta > 0 {
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 // To To Do
}
case modal.FocusColumn3: // Blocked
if delta > 0 {
newCol = modal.FocusColumn4 // To Done
} else {
newCol = modal.FocusColumn2 // To In Progress
}
case modal.FocusColumn4: // Done
if delta < 0 {
newCol = modal.FocusColumn3 // To Blocked
}
}
if newCol == modal.FocusNone {
return nil
}
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)()
}
}