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 routes
import (
"fmt"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/pkg/web/components"
"github.com/LazyBachelor/LazyPM/pkg/web/components/base"
@@ -21,17 +22,14 @@ templ Dashboard(props DashboardProps) {
}
templ DashboardContent(props DashboardProps) {
<div class="flex flex-col h-full">
{{
issuesJSON, _ := templ.JSONString(props.Issues)
xData := fmt.Sprintf(`{ selectedId: '%s', issues: %s }`, props.SelectedIssue.ID, issuesJSON)
}}
<div class="flex flex-col h-full" x-data={ xData }>
<div class="flex flex-col border-b border-base-200">
<div class="flex p-2 border-b border-base-200 items-center gap-2">
@components.SearchForm(components.SearchFormProps{
RootURL: props.BaseURL,
SearchQuery: props.QueryParam,
Target: "#issue-list-container",
})
</div>
<div class="flex p-2 justify-between items-center gap-2">
<div class="flex gap-2">
<div class="flex gap-2 items-center">
<div class="join">
<button class="btn btn-sm join-item btn-active btn-primary">List</button>
<button
@@ -50,44 +48,293 @@ templ DashboardContent(props DashboardProps) {
>
New Issue
</button>
</div>
<div class="flex gap-2">
if props.SelectedIssue != nil {
<button
type="button"
class="btn btn-primary btn-sm"
hx-get={ "/issues/" + props.SelectedIssue.ID + "/edit" }
hx-target="#modal-container"
hx-swap="innerHTML"
>
Edit
</button>
<button
class="btn btn-sm"
hx-get={ "/issues/" + props.SelectedIssue.ID }
hx-target="main"
hx-swap="innerHTML"
hx-push-url="true"
>Details</button>
}
<div class="flex gap-2" x-show="selectedId">
<button
class="btn btn-sm hidden lg:inline-flex"
@click="window.location.href = '/issues/' + selectedId"
>
Full Details
</button>
</div>
</div>
</div>
<div class="flex flex-row flex-1 overflow-hidden">
<div class="w-full flex flex-col border-r border-base-200 overflow-auto">
<div
class="flex flex-1 overflow-hidden relative flex-row"
x-data="{
listWidth: localStorage.getItem('dashboardListWidth') || 33.33,
isResizing: false,
startX: 0,
startWidth: 0,
init() {
this.listWidth = parseFloat(this.listWidth);
},
startResize(e) {
this.isResizing = true;
this.startX = e.clientX || e.touches[0].clientX;
this.startWidth = this.listWidth;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
stopResize() {
if (this.isResizing) {
this.isResizing = false;
localStorage.setItem('dashboardListWidth', this.listWidth);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
},
resize(e) {
if (!this.isResizing) return;
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
const delta = ((clientX - this.startX) / window.innerWidth) * 100;
this.listWidth = Math.max(20, Math.min(60, this.startWidth + delta));
}
}"
@mousemove.window="resize($event)"
@mouseup.window="stopResize()"
@touchmove.window="resize($event)"
@touchend.window="stopResize()"
>
<div
class="flex flex-col border-r border-base-200 overflow-auto bg-base-100 lg:flex lg:w-auto"
:style="`width: ${listWidth}%`"
>
<div id="issue-list-container" class="p-2">
@DashboardIssueList(props.Issues, props.SelectedIssue.ID)
</div>
</div>
<div class="w-full p-2 overflow-auto max-[770px]:hidden">
if props.SelectedIssue != nil {
<div id="issue-detail-container">
@components.IssueDetail(components.IssueDetailProps{
Issue: props.SelectedIssue,
})
<div
class="w-1 bg-base-300 hover:bg-primary cursor-col-resize transition-colors relative z-10"
:class="{ 'bg-primary': isResizing }"
@mousedown="startResize($event)"
@touchstart="startResize($event)"
title="Drag to resize"
>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-8 bg-base-300 rounded-full flex items-center justify-center">
<div class="w-0.5 h-4 bg-base-content/30 rounded-full"></div>
</div>
</div>
<div
class="flex-1 p-4 overflow-auto bg-base-50"
>
<template x-for="issue in issues" :key="issue.id">
<div x-show="issue.id === selectedId" class="issue-detail max-w-3xl mx-auto" x-data="{ editing: null, saving: false, newComment: '', async addComment() { if (!this.newComment.trim()) return; const author = issue.created_by || 'Anonymous'; const response = await fetch('/issues/' + issue.id + '/comments', { method: 'POST', body: new URLSearchParams({text: this.newComment, author: author}) }); const newComment = await response.json(); if (!issue.comments) issue.comments = []; issue.comments.push(newComment); this.newComment = ''; } }">
<div class="card bg-base-100 shadow-md">
<div class="card-body p-2">
<div class="m-2">
<template x-if="editing !== 'title'">
<h2
class="text-2xl font-bold cursor-pointer hover:text-primary"
x-text="issue.title"
@click="editing = 'title'"
></h2>
</template>
<template x-if="editing === 'title'">
<div class="flex gap-2">
<input
type="text"
name="title"
x-model="issue.title"
class="input input-bordered flex-1"
@keydown.enter="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({title: issue.title}) }).then(() => editing = null)"
@keydown.escape="editing = null"
x-init="$el.focus()"
/>
<button class="btn btn-sm btn-primary" @click="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({title: issue.title}) }).then(() => editing = null)">Save</button>
<button class="btn btn-sm" @click="editing = null">Cancel</button>
</div>
</template>
</div>
<div class="flex gap-2 flex-wrap items-center">
<template x-if="editing !== 'status'">
<span
class="badge badge-info badge-sm cursor-pointer hover:badge-primary whitespace-nowrap shrink-0"
x-text="issue.status.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())"
@click="editing = 'status'"
></span>
</template>
<template x-if="editing === 'status'">
<div class="flex items-center gap-2">
<select
name="status"
x-model="issue.status"
class="select select-sm select-bordered"
@change="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({status: issue.status}) }).then(() => editing = null)"
x-init="$nextTick(() => $el.focus())"
>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="blocked">Blocked</option>
<option value="closed">Closed</option>
</select>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</template>
<template x-if="editing !== 'type'">
<span
class="badge badge-outline badge-sm cursor-pointer hover:badge-primary whitespace-nowrap"
x-text="issue.issue_type"
@click="editing = 'type'"
></span>
</template>
<template x-if="editing === 'type'">
<div class="flex items-center gap-2">
<select
name="issue_type"
x-model="issue.issue_type"
class="select select-sm select-bordered"
@change="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({issue_type: issue.issue_type}) }).then(() => editing = null)"
x-init="$nextTick(() => $el.focus())"
>
<option value="task">Task</option>
<option value="bug">Bug</option>
<option value="feature">Feature</option>
<option value="chore">Chore</option>
</select>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</template>
<template x-if="editing !== 'priority'">
<span
class="badge badge-ghost badge-sm cursor-pointer hover:badge-primary whitespace-nowrap"
x-text="'P' + issue.priority"
@click="editing = 'priority'"
></span>
</template>
<template x-if="editing === 'priority'">
<div class="flex items-center gap-2">
<select
name="priority"
x-model="issue.priority"
class="select select-sm select-bordered"
@change="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({priority: issue.priority}) }).then(() => editing = null)"
x-init="$nextTick(() => $el.focus())"
>
<option value="0">Irrelevant (P0)</option>
<option value="1">Low (P1)</option>
<option value="2">Normal (P2)</option>
<option value="3">High (P3)</option>
<option value="4">Critical (P4)</option>
</select>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</template>
</div>
<div class="mt-4">
<h3 class="text-sm font-semibold opacity-70 mb-1">Description</h3>
<template x-if="editing !== 'description'">
<p
class="whitespace-pre-wrap text-sm cursor-pointer hover:text-primary min-h-[20px]"
x-text="issue.description || 'Click to add description...'"
@click="editing = 'description'"
></p>
</template>
<template x-if="editing === 'description'">
<div class="flex flex-col gap-2">
<textarea
name="description"
x-model="issue.description"
class="textarea textarea-bordered"
rows="4"
@keydown.escape="editing = null"
x-init="$el.focus()"
></textarea>
<div class="flex gap-2">
<button
class="btn btn-sm btn-primary"
@click="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({description: issue.description || ''}) }); editing = null"
>Save</button>
<button class="btn btn-sm" @click="editing = null">Cancel</button>
</div>
</div>
</template>
</div>
<div class="divider my-2"></div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="bg-base-200 rounded-lg p-2">
<span class="text-xs opacity-70 block">Created</span>
<p x-text="new Date(issue.created_at).toLocaleDateString()"></p>
</div>
<div class="bg-base-200 rounded-lg p-2">
<span class="text-xs opacity-70 block">Updated</span>
<p x-text="new Date(issue.updated_at).toLocaleDateString()"></p>
</div>
<div class="flex flex-row gap-1 bg-base-200 rounded-lg p-2">
<span class="text-xs opacity-70 block">Created by</span>
<p class="text-xs opacity-70" x-text="issue.created_by"></p>
</div>
<div class="bg-base-200 rounded-lg p-2 cursor-pointer hover:bg-base-300" @click="editing = 'assignee'">
<template x-if="editing !== 'assignee'">
<div>
<span class="text-xs opacity-70 block">Assignee</span>
<p class="text-sm" x-text="issue.assignee || 'Unassigned (click to assign)'"></p>
</div>
</template>
<template x-if="editing === 'assignee'">
<div class="flex flex-col gap-1">
<span class="text-xs opacity-70 block">Assignee</span>
<input
type="text"
name="assignee"
x-model="issue.assignee"
class="input input-sm input-bordered"
placeholder="Enter assignee name"
@keydown.enter="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({assignee: issue.assignee || ''}) }); editing = null"
@keydown.escape="editing = null"
x-init="$el.focus()"
/>
<div class="flex gap-1 mt-1">
<button
class="btn btn-xs btn-primary"
@click="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({assignee: issue.assignee || ''}) }); editing = null"
>Save</button>
<button class="btn btn-xs" @click="editing = null">Cancel</button>
</div>
</div>
</template>
</div>
</div>
<div class="mt-6 w-full">
<h3 class="text-lg font-semibold mb-4">Comments</h3>
<div class="card bg-base-100 shadow-sm mb-3">
<div class="card-body p-3">
<textarea
x-model="newComment"
class="textarea textarea-bordered textarea-sm w-full"
rows="2"
placeholder="Add a comment..."
@keydown.enter.prevent="addComment()"
></textarea>
<div class="card-actions justify-end mt-2">
<button
class="btn btn-primary btn-xs"
@click="addComment()"
:disabled="!newComment.trim()"
>
Add
</button>
</div>
</div>
</div>
<div class="space-y-4">
<template x-for="comment in issue.comments" :key="comment.id">
<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" x-text="comment.author"></span>
<span class="text-xs opacity-50" x-text="new Date(comment.created_at).toLocaleString()"></span>
</div>
<p class="whitespace-pre-wrap text-sm" x-text="comment.text"></p>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
}
</template>
</div>
</div>
</div>
@@ -116,23 +363,14 @@ templ DashboardIssueRows(issues []*models.Issue, selectedID string) {
<tr><td colspan="5" class="text-center py-4">No issues</td></tr>
}
for _, issue := range issues {
{{
var active string
if selectedID == issue.ID {
active = "bg-primary/10"
}
}}
<tr
hx-get="/"
hx-trigger="click delay:400ms cancel:dblclick"
class={ "hover cursor-pointer min-h-full w-full select-none", active }
hx-target="main"
hx-vals={ `{"selected-issue": "` + issue.ID + `"}` }
hx-swap="innerHTML"
@click={ fmt.Sprintf(`selectedId = '%s'`, issue.ID) }
:class={ fmt.Sprintf(`{ 'bg-primary/10': selectedId === '%s' }`, issue.ID) }
class="hover cursor-pointer min-h-full w-full select-none"
data-issue-id={ issue.ID }
>
<td class="min-w-20 truncate">{ issue.ID }</td>
<td class="truncate max-w-60">{ issue.Title }</td>
<td class="truncate max-w-60" x-text={ fmt.Sprintf(`issues.find(i => i.id === '%s')?.title || %q`, issue.ID, issue.Title) }></td>
<td>
@components.StatusBadge(issue.Status)
</td>