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,77 +1,272 @@
package routes
import (
"fmt"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/pkg/web/components"
)
type IssueDetailProps struct {
Issue *models.Issue
Comments []*models.Comment
From string // "board" when navigating from board view
}
type IssueDetailModalProps struct {
Issue *models.Issue
Comments []*models.Comment
From string
}
templ IssueDetailContent(props IssueDetailProps) {
templ IssueDetailPage(issue *models.Issue, comments []*models.Comment) {
{{
backURL := "/"
editURL := "/issues/" + props.Issue.ID + "/edit"
if props.From == "board" {
backURL = "/?board=true"
editURL += "?from=board"
}
issueJSON, _ := templ.JSONString(issue)
xData := fmt.Sprintf(`{
issue: %s,
editing: null,
newComment: '',
get statusClass() {
const classes = {
'open': 'badge-info',
'in_progress': 'badge-warning',
'blocked': 'badge-error',
'closed': 'badge-success'
}
return classes[this.issue.status] || 'badge-ghost'
},
get priorityLabel() {
const labels = ['Irrelevant', 'Low', 'Normal', 'High', 'Critical']
return labels[this.issue.priority] || 'P' + this.issue.priority
},
async saveField(field, value) {
const formData = new URLSearchParams()
formData.append(field, value || '')
const response = await fetch('/issues/' + this.issue.id, {
method: 'PATCH',
body: formData,
headers: { 'HX-Request': 'true' }
})
this.editing = null
// Check if server wants to redirect back to list
const redirectUrl = response.headers.get('HX-Redirect')
if (redirectUrl) {
window.location.href = redirectUrl
}
},
async addComment() {
if (!this.newComment.trim()) return
const author = this.issue.created_by || 'Anonymous'
const formData = new URLSearchParams()
formData.append('text', this.newComment)
formData.append('author', author)
await fetch('/issues/' + this.issue.id + '/comments', {
method: 'POST',
body: formData,
headers: { 'HX-Request': 'true' }
})
this.newComment = ''
window.location.reload()
}
}`, issueJSON)
}}
<div class="max-w-2xl mx-auto">
<div class="mb-4">
<a
class="btn btn-ghost btn-sm"
hx-get={ backURL }
hx-target="main"
hx-swap="innerHTML"
hx-push-url="true"
>
@components.IconBack()
Back to Issues
</a>
<button
type="button"
class="btn btn-primary btn-sm"
hx-get={ editURL }
hx-target="#modal-container"
hx-swap="innerHTML"
>
Edit
</button>
</div>
<div id="issue-detail-container" class="mb-6">
@components.IssueDetail(components.IssueDetailProps{Issue: props.Issue})
</div>
@components.CommentSection(components.CommentSectionProps{
IssueID: props.Issue.ID,
Comments: props.Comments,
})
</div>
}
templ IssueDetail(props IssueDetailProps) {
@BaseLayout() {
@IssueDetailContent(props)
<div class="max-w-4xl mx-auto p-4" x-data={ xData }>
<div class="flex justify-between items-center mb-6">
<a href="/" class="btn btn-ghost btn-sm">
@components.IconBack()
Back to Issues
</a>
<div class="flex gap-2">
<button
class="btn btn-error btn-sm"
@click="if(confirm('Delete this issue?')) { fetch('/issues/' + issue.id, { method: 'DELETE' }).then(() => window.location.href = '/') }"
>
Delete
</button>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="mb-4">
<template x-if="editing !== 'title'">
<h1
class="card-title text-3xl cursor-pointer hover:text-primary"
@click="editing = 'title'"
x-text="issue.title"
></h1>
</template>
<template x-if="editing === 'title'">
<div class="flex gap-2">
<input
type="text"
name="title"
x-model="issue.title"
class="input input-bordered text-xl flex-1"
@keydown.enter="saveField('title', issue.title)"
@keydown.escape="editing = null"
x-init="$el.focus()"
/>
<button class="btn btn-primary" @click="saveField('title', issue.title)">Save</button>
<button class="btn" @click="editing = null">Cancel</button>
</div>
</template>
<div class="text-sm opacity-60 mt-1" x-text="issue.id"></div>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<template x-if="editing !== 'status'">
<span
:class="'badge ' + statusClass + ' cursor-pointer hover:opacity-80 whitespace-nowrap'"
@click="editing = 'status'"
x-text="issue.status"
></span>
</template>
<template x-if="editing === 'status'">
<select
name="status"
x-model="issue.status"
class="select select-sm select-bordered"
@change="saveField('status', issue.status)"
>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="blocked">Blocked</option>
<option value="closed">Closed</option>
</select>
</template>
<template x-if="editing !== 'type'">
<span
class="badge badge-outline cursor-pointer hover:opacity-80 whitespace-nowrap"
@click="editing = 'type'"
x-text="issue.issue_type"
></span>
</template>
<template x-if="editing === 'type'">
<select
name="issue_type"
x-model="issue.issue_type"
class="select select-sm select-bordered"
@change="saveField('issue_type', issue.issue_type)"
>
<option value="task">Task</option>
<option value="bug">Bug</option>
<option value="feature">Feature</option>
<option value="chore">Chore</option>
</select>
</template>
<template x-if="editing !== 'priority'">
<span
class="badge badge-ghost cursor-pointer hover:opacity-80 whitespace-nowrap"
@click="editing = 'priority'"
x-text="'P' + issue.priority + ' - ' + priorityLabel"
></span>
</template>
<template x-if="editing === 'priority'">
<select
name="priority"
x-model="issue.priority"
class="select select-sm select-bordered"
@change="saveField('priority', issue.priority)"
>
<option value="0">P0 - Irrelevant</option>
<option value="1">P1 - Low</option>
<option value="2">P2 - Normal</option>
<option value="3">P3 - High</option>
<option value="4">P4 - Critical</option>
</select>
</template>
</div>
<div class="mb-6">
<h3 class="text-sm font-semibold opacity-70 mb-2">Description</h3>
<template x-if="editing !== 'description'">
<div
class="min-h-25 p-4 bg-base-200 rounded-lg cursor-pointer hover:bg-base-300"
@click="editing = 'description'"
>
<p class="whitespace-pre-wrap" x-show="issue.description" x-text="issue.description"></p>
<p class="text-base-content/50 italic" x-show="!issue.description">Click to add description...</p>
</div>
</template>
<template x-if="editing === 'description'">
<div class="flex flex-col gap-2">
<textarea
name="description"
x-model="issue.description"
class="textarea textarea-bordered min-h-37.5"
@keydown.escape="editing = null"
x-init="$el.focus()"
></textarea>
<div class="flex gap-2">
<button class="btn btn-primary" @click="saveField('description', issue.description)">Save</button>
<button class="btn" @click="editing = null">Cancel</button>
</div>
</div>
</template>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div class="bg-base-200 rounded-lg p-3">
<span class="text-xs opacity-60 block">Created</span>
<p x-text="new Date(issue.created_at).toLocaleDateString()"></p>
<p class="text-xs opacity-50" x-text="issue.created_by"></p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="text-xs opacity-60 block">Updated</span>
<p x-text="new Date(issue.updated_at).toLocaleDateString()"></p>
</div>
<div
class="bg-base-200 rounded-lg p-3 cursor-pointer hover:bg-base-300"
@click="editing = 'assignee'"
>
<template x-if="editing !== 'assignee'">
<div>
<span class="text-xs opacity-60 block">Assignee</span>
<p x-text="issue.assignee || 'Unassigned'"></p>
</div>
</template>
<template x-if="editing === 'assignee'">
<div>
<span class="text-xs opacity-60 block">Assignee</span>
<input
type="text"
name="assignee"
x-model="issue.assignee"
class="input input-sm input-bordered w-full"
@keydown.enter="saveField('assignee', issue.assignee)"
@keydown.escape="editing = null"
x-init="$el.focus()"
/>
<div class="flex gap-1 mt-1">
<button class="btn btn-xs btn-primary" @click="saveField('assignee', issue.assignee)">Save</button>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="mt-6">
<h3 class="text-lg font-semibold mb-4">Comments</h3>
<div class="card bg-base-100 shadow mb-4">
<div class="card-body">
<textarea
x-model="newComment"
class="textarea textarea-bordered w-full"
placeholder="Add a comment..."
@keydown.enter.prevent="addComment()"
></textarea>
<div class="card-actions justify-end mt-2">
<button
class="btn btn-primary btn-sm"
@click="addComment()"
:disabled="!newComment.trim()"
>
Add Comment
</button>
</div>
</div>
</div>
<div class="space-y-4">
for _, comment := range comments {
<div class="card bg-base-200">
<div class="card-body py-3">
<div class="flex justify-between items-start mb-2">
<span class="font-semibold text-sm">{ comment.Author }</span>
<span class="text-xs opacity-50">{ comment.CreatedAt.Format("Jan 2, 2006 15:04") }</span>
</div>
<p class="whitespace-pre-wrap text-sm">{ comment.Text }</p>
</div>
</div>
}
</div>
</div>
</div>
}
}
templ IssueDetailModalContent(props IssueDetailModalProps) {
<div class="w-full">
<div id="issue-detail-container" class="mb-6">
@components.IssueDetail(components.IssueDetailProps{Issue: props.Issue})
</div>
@components.CommentSection(components.CommentSectionProps{
IssueID: props.Issue.ID,
Comments: props.Comments,
})
</div>
}