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

@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"sort"
"strings"
@@ -21,24 +22,72 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Sort issues by highest priority first (4 -> 0)
sort.Slice(issues, func(i, j int) bool {
return issues[i].Priority > issues[j].Priority
})
// Check if board view is requested
isBoardView := r.URL.Query().Get("board") == "true"
isBoardView := r.URL.Query().Get("board") == "true" || strings.HasPrefix(r.URL.Path, "/board/")
if isBoardView {
ctx := r.Context()
backlogIssues, err := app.Issues.GetIssuesNotInAnySprint(ctx)
if err != nil {
backlogIssues = []*models.Issue{}
}
sort.Slice(backlogIssues, func(i, j int) bool {
return backlogIssues[i].Priority > backlogIssues[j].Priority
})
sprints, err := app.Issues.GetSprints(ctx)
if err != nil {
sprints = []int{}
}
currentSprint := 0
sprintParam := r.URL.Query().Get("sprint")
if sprintParam != "" {
fmt.Sscanf(sprintParam, "%d", &currentSprint)
}
if currentSprint == 0 && len(sprints) > 0 {
currentSprint = sprints[0]
}
var sprintIssues []*models.Issue
if currentSprint > 0 {
sprintIssues, err = app.Issues.GetIssuesBySprint(ctx, currentSprint)
if err != nil {
sprintIssues = []*models.Issue{}
}
}
sort.Slice(sprintIssues, func(i, j int) bool {
return sprintIssues[i].Priority > sprintIssues[j].Priority
})
boardProps := routes.BoardViewProps{
BaseURL: "/?board=true",
QueryParam: query,
Issues: issues,
EmptyText: "No issues found",
BaseURL: "/?board=true",
QueryParam: query,
Issues: issues,
BacklogIssues: backlogIssues,
SprintIssues: sprintIssues,
CurrentSprint: currentSprint,
Sprints: sprints,
EmptyText: "No issues found",
}
if hx.IsHxRequest() {
routes.BoardViewContent(boardProps).Render(r.Context(), w)
if r.URL.Query().Get("board") != "true" {
w.Header().Set("HX-Push-Url", fmt.Sprintf("/?board=true&sprint=%d", currentSprint))
}
if strings.HasPrefix(r.URL.Path, "/board/") {
routes.BoardColumns(boardProps).Render(r.Context(), w)
} else {
routes.BoardViewContent(boardProps).Render(r.Context(), w)
}
return
}
@@ -46,7 +95,6 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Regular dashboard view
var selectedIssue *models.Issue
if len(issues) > 0 {
selectedIssue = issues[0]
@@ -62,6 +110,13 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) {
}
}
if selectedIssue != nil {
comments, err := app.Issues.GetIssueComments(r.Context(), selectedIssue.ID)
if err == nil && comments != nil {
selectedIssue.Comments = comments
}
}
isPartial := r.URL.Query().Get("partial") == "true"
if hx.IsHxRequest() && isPartial {
routes.DashboardIssueList(issues, selectedIssueID).Render(r.Context(), w)
@@ -83,3 +138,18 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) {
routes.Dashboard(props).Render(r.Context(), w)
}
func CreateSprintHandler(w http.ResponseWriter, r *http.Request) {
app := App(r)
ctx := r.Context()
sprintNum, err := app.Issues.AddSprint(ctx)
if err != nil {
http.Error(w, "Failed to create sprint", http.StatusInternalServerError)
return
}
redirectURL := fmt.Sprintf("/?board=true&sprint=%d", sprintNum)
w.Header().Set("HX-Redirect", redirectURL)
w.WriteHeader(http.StatusOK)
}

View File

@@ -2,8 +2,10 @@ package handler
import (
"context"
"fmt"
"html"
"net/http"
"net/url"
"strings"
"github.com/LazyBachelor/LazyPM/internal/models"
@@ -17,7 +19,7 @@ const commentsKey = "comments"
type IssueForm struct {
Title string `form:"title" validate:"required,max=255"`
Description string `form:"description" validate:"max=2000"`
Status models.Status `form:"status" validate:"required,oneof=open in_progress blocked ready_to_sprint closed"`
Status models.Status `form:"status" validate:"required,oneof=open in_progress blocked closed"`
IssueType models.IssueType `form:"issue_type" validate:"required,oneof=task bug feature chore"`
Priority int `form:"priority" validate:"gte=0,lte=4"`
}
@@ -25,10 +27,11 @@ type IssueForm struct {
type UpdateIssueForm struct {
Title *string `form:"title" validate:"omitempty,max=255"`
Description *string `form:"description" validate:"omitempty,max=2000"`
Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress blocked ready_to_sprint closed"`
Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress blocked closed"`
CloseReason *string `form:"close_reason" validate:"omitempty,max=2000"`
IssueType *models.IssueType `form:"issue_type" validate:"omitempty,oneof=task bug feature chore"`
Priority *int `form:"priority" validate:"omitempty,gte=0,lte=4"`
Assignee *string `form:"assignee" validate:"omitempty,max=100"`
}
func CreateIssue(w http.ResponseWriter, r *http.Request) {
@@ -122,20 +125,8 @@ func GetIssue(w http.ResponseWriter, r *http.Request) {
comments := r.Context().Value(commentsKey).([]*models.Comment)
hx := HTMX(r)
from := r.URL.Query().Get("from")
detailProps := routes.IssueDetailProps{
Issue: issue,
Comments: comments,
From: from,
}
if !hx.IsHxRequest() && strings.Contains(r.Header.Get("Accept"), "text/html") {
routes.IssueDetail(detailProps).Render(r.Context(), w)
return
}
if hx.IsHxRequest() {
routes.IssueDetailContent(detailProps).Render(r.Context(), w)
if strings.Contains(r.Header.Get("Accept"), "text/html") && !hx.IsHxRequest() {
routes.IssueDetailPage(issue, comments).Render(r.Context(), w)
return
}
@@ -170,6 +161,24 @@ func UpdateIssue(w http.ResponseWriter, r *http.Request) {
return
}
ctx := r.Context()
addToSprint := r.FormValue("add_to_sprint")
removeFromSprint := r.FormValue("remove_from_sprint")
if addToSprint != "" {
sprintNum := 0
fmt.Sscanf(addToSprint, "%d", &sprintNum)
if sprintNum > 0 {
app.Issues.AddIssueToSprint(ctx, issue.ID, sprintNum)
}
} else if removeFromSprint != "" {
sprintNum := 0
fmt.Sscanf(removeFromSprint, "%d", &sprintNum)
if sprintNum > 0 {
app.Issues.RemoveIssueFromSprint(ctx, issue.ID, sprintNum)
}
}
issue, err = app.Issues.GetIssue(r.Context(), issue.ID)
if err != nil {
http.Error(w, "Failed to retrieve updated issue", http.StatusInternalServerError)
@@ -177,13 +186,20 @@ func UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
if hx.IsHxRequest() {
// Check if 'from' parameter says board view
from := r.URL.Query().Get("from")
referer := r.Header.Get("Referer")
boardView := from == "board" || strings.Contains(referer, "board=true")
if boardView {
w.Header().Set("HX-Redirect", "/?board=true")
redirectURL := "/?board=true"
if strings.Contains(referer, "sprint=") {
if parsedURL, err := url.Parse(referer); err == nil {
if sprintParam := parsedURL.Query().Get("sprint"); sprintParam != "" {
redirectURL = redirectURL + "&sprint=" + sprintParam
}
}
}
w.Header().Set("HX-Redirect", redirectURL)
} else {
w.Header().Set("HX-Redirect", "/?selected-issue="+issue.ID)
}
@@ -329,5 +345,8 @@ func (f *UpdateIssueForm) toChanges() map[string]any {
if f.Priority != nil {
changes["priority"] = *f.Priority
}
if f.Assignee != nil {
changes["assignee"] = *f.Assignee
}
return changes
}