fix: Sprint-Planung einsehbar und Eingang ohne Voll-Reload.
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Successful in 2m46s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s

Geplante Sprints in Plan anwaehlbar, lokales Update beim Commit, Sprint-Auswahl bleibt erhalten.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-12 19:38:39 +02:00
parent c19b6296ed
commit e221b5b09f
8 changed files with 269 additions and 70 deletions

View File

@ -12,12 +12,14 @@ import { useMinWidth } from '../hooks/useMinWidth.js'
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
/** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */
const COMMITTABLE_STATUSES = new Set(['accepted', 'triaged', 'new'])
const SPRINT_TARGET_STORAGE_PREFIX = 'kairo-sprint-target:'
export function BacklogSection({
items,
roadmapItems = [],
workCycles = [],
activeWorkCycle = null,
initiativeId = '',
sprintPlanningEnabled = false,
canManage,
onCreate,
@ -46,8 +48,12 @@ export function BacklogSection({
const showSprintPlanning = sprintPlanningEnabled && planableSprints.length > 0
useEffect(() => {
if (!showSprintPlanning) {
setSprintTargetId('')
if (!showSprintPlanning || !initiativeId) {
return
}
const stored = sessionStorage.getItem(SPRINT_TARGET_STORAGE_PREFIX + initiativeId)
if (stored && planableSprints.some((cycle) => cycle.id === stored)) {
setSprintTargetId(stored)
return
}
if (sprintTargetId && planableSprints.some((cycle) => cycle.id === sprintTargetId)) {
@ -55,7 +61,12 @@ export function BacklogSection({
}
const preferred = activeWorkCycle?.id || planableSprints[0]?.id || ''
setSprintTargetId(preferred)
}, [showSprintPlanning, sprintTargetId, planableSprints, activeWorkCycle?.id])
}, [showSprintPlanning, initiativeId, planableSprints, activeWorkCycle?.id])
useEffect(() => {
if (!initiativeId || !sprintTargetId) return
sessionStorage.setItem(SPRINT_TARGET_STORAGE_PREFIX + initiativeId, sprintTargetId)
}, [initiativeId, sprintTargetId])
const selectedSprint = planableSprints.find((cycle) => cycle.id === sprintTargetId) || null

View File

@ -5,10 +5,13 @@ import { EmptyState } from './EmptyState.jsx'
export function WorkCyclesPanel({
workCycles = [],
activeWorkCycle = null,
selectedSprintId = '',
actionCountByCycleId = {},
canManage,
onCreate,
onActivate,
onComplete,
onSelectSprint,
busy = false,
}) {
const [title, setTitle] = useState('')
@ -34,8 +37,8 @@ export function WorkCyclesPanel({
<div>
<h2>Sprint</h2>
<p className="section-lead muted">
Der aktive Sprint definiert den Sprint-Backlog committete Arbeitspakete mit
Sprint-Zuweisung erscheinen unter Ausführen Sprint.
Sprint anklicken, um den geplanten Sprint-Backlog zu sehen aktiv oder geplant.
Committete Arbeitspakete aus dem Eingang erscheinen beim gewählten Sprint.
</p>
</div>
</div>
@ -46,12 +49,9 @@ export function WorkCyclesPanel({
{activeWorkCycle.goal_description && (
<span className="muted"> {activeWorkCycle.goal_description}</span>
)}
{typeof activeWorkCycle.open_action_count === 'number' && (
<span className="muted"> ({activeWorkCycle.open_action_count} offene APs)</span>
)}
</p>
) : (
<p className="muted">Kein aktiver Sprint committete Arbeitspakete gelten als Product-Ist.</p>
<p className="muted">Kein aktiver Sprint du kannst trotzdem geplante Sprints einsehen.</p>
)}
{canManage && (
@ -91,38 +91,57 @@ export function WorkCyclesPanel({
{workCycles.length === 0 ? (
<EmptyState message="Noch keine Sprints angelegt." />
) : (
<ul className="item-list">
{workCycles.map((cycle) => (
<li key={cycle.id} className="list-item card-list-item work-cycle-row">
<div>
<strong>{cycle.title}</strong>{' '}
<StatusBadge status={cycle.status} />
{cycle.goal_description && (
<p className="muted item-meta">{cycle.goal_description}</p>
)}
</div>
{canManage && cycle.status === 'active' && typeof onComplete === 'function' && (
<ul className="item-list work-cycle-list">
{workCycles.map((cycle) => {
const isSelected = selectedSprintId === cycle.id
const apCount = actionCountByCycleId[cycle.id] || 0
return (
<li
key={cycle.id}
className={
'list-item card-list-item work-cycle-row' +
(isSelected ? ' work-cycle-row--selected' : '')
}
>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onComplete(cycle.id)}
className="work-cycle-row__select"
onClick={() => onSelectSprint?.(cycle.id)}
>
Abschließen
<strong>{cycle.title}</strong>{' '}
<StatusBadge status={cycle.status} />
<span className="muted work-cycle-row__count">
{apCount} AP{apCount === 1 ? '' : 's'}
</span>
{cycle.goal_description && (
<p className="muted item-meta">{cycle.goal_description}</p>
)}
</button>
)}
{canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onActivate(cycle.id)}
>
Aktivieren
</button>
)}
</li>
))}
<div className="work-cycle-row__actions">
{canManage && cycle.status === 'active' && typeof onComplete === 'function' && (
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onComplete(cycle.id)}
>
Abschließen
</button>
)}
{canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onActivate(cycle.id)}
>
Aktivieren
</button>
)}
</div>
</li>
)
})}
</ul>
)}
</section>

View File

@ -83,6 +83,7 @@ import { useActors } from '../hooks/useActors.js'
import { useSession } from './SessionContext.jsx'
import { useOptionalProgramScope } from './ProgramScopeContext.jsx'
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
import { filterActionsForWorkCycle } from '../utils/workCycleActions.js'
const InitiativeOperationsContext = createContext(null)
@ -118,10 +119,11 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
const [editingAction, setEditingAction] = useState(null)
const [formBusy, setFormBusy] = useState(false)
const [hideDone, setHideDone] = useState(true)
const [selectedWorkCycleId, setSelectedWorkCycleId] = useState('')
const load = useCallback(async () => {
const load = useCallback(async ({ silent = false } = {}) => {
if (sessionLoading || !id) return
setLoading(true)
if (!silent) setLoading(true)
setError(null)
try {
const [initData, actionData] = await Promise.all([
@ -203,18 +205,63 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
const sprintActions = useMemo(() => {
if (!activeWorkCycle?.id) return []
const cycleId = activeWorkCycle.id
let list = hideDone
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions
list = list.filter((a) => a.work_cycle_id === cycleId)
if (selectedProjectId) {
return filterActionsForWorkCycle(actions, activeWorkCycle.id, { hideDone }).filter((a) => {
if (!selectedProjectId) return true
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
}
return list
return a.project_id && subtreeIds.has(a.project_id)
})
}, [actions, hideDone, activeWorkCycle?.id, selectedProjectId, projects])
const selectedSprintActions = useMemo(() => {
if (!selectedWorkCycleId) return []
return filterActionsForWorkCycle(actions, selectedWorkCycleId, { hideDone }).filter((a) => {
if (!selectedProjectId) return true
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
return a.project_id && subtreeIds.has(a.project_id)
})
}, [actions, hideDone, selectedWorkCycleId, selectedProjectId, projects])
const selectedWorkCycle = useMemo(
() => workCycles.find((c) => c.id === selectedWorkCycleId) || null,
[workCycles, selectedWorkCycleId],
)
useEffect(() => {
if (selectedWorkCycleId && workCycles.some((c) => c.id === selectedWorkCycleId)) {
return
}
if (activeWorkCycle?.id) {
setSelectedWorkCycleId(activeWorkCycle.id)
return
}
const planned = workCycles.find((c) => c.status === 'planned')
if (planned) setSelectedWorkCycleId(planned.id)
else if (workCycles[0]?.id) setSelectedWorkCycleId(workCycles[0].id)
else setSelectedWorkCycleId('')
}, [workCycles, activeWorkCycle, selectedWorkCycleId])
function applyConvertResults(results) {
const list = Array.isArray(results) ? results : [results]
for (const result of list) {
if (!result) continue
const backlogItem = result.backlog_item
const action = result.action
if (backlogItem?.id) {
setBacklogItems((prev) =>
prev.map((item) => (item.id === backlogItem.id ? backlogItem : item)),
)
}
if (action?.id) {
setActions((prev) => {
if (prev.some((a) => a.id === action.id)) {
return prev.map((a) => (a.id === action.id ? action : a))
}
return [...prev, action]
})
}
}
}
const actionContextById = useMemo(
() => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])),
[steeringSnapshot]
@ -423,12 +470,16 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleConvertBacklog(itemId, options = {}) {
setFormBusy(true)
setError(null)
try {
await convertBacklogToAction(itemId, {
const result = await convertBacklogToAction(itemId, {
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
work_cycle_id: options.work_cycle_id,
})
await load()
applyConvertResults(result)
if (options.work_cycle_id) {
setSelectedWorkCycleId(options.work_cycle_id)
}
} catch (err) {
setError(err.message)
} finally {
@ -439,14 +490,20 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleBulkConvertBacklog(itemIds, options = {}) {
if (!itemIds.length) return
setFormBusy(true)
setError(null)
try {
for (const itemId of itemIds) {
await convertBacklogToAction(itemId, {
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
work_cycle_id: options.work_cycle_id,
})
const results = await Promise.all(
itemIds.map((itemId) =>
convertBacklogToAction(itemId, {
assign_active_sprint: options.work_cycle_id ? false : options.assign_active_sprint ?? true,
work_cycle_id: options.work_cycle_id,
}),
),
)
applyConvertResults(results)
if (options.work_cycle_id) {
setSelectedWorkCycleId(options.work_cycle_id)
}
await load()
} catch (err) {
setError(err.message)
} finally {
@ -457,8 +514,9 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleCreateWorkCycle(body) {
setFormBusy(true)
try {
await createWorkCycle(id, body)
await load()
const created = await createWorkCycle(id, body)
await load({ silent: true })
if (created?.id) setSelectedWorkCycleId(created.id)
} catch (err) {
setError(err.message)
} finally {
@ -470,7 +528,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true)
try {
await activateWorkCycle(id, workCycleId)
await load()
await load({ silent: true })
setSelectedWorkCycleId(workCycleId)
} catch (err) {
setError(err.message)
} finally {
@ -482,7 +541,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setFormBusy(true)
try {
await completeWorkCycle(id, workCycleId)
await load()
await load({ silent: true })
} catch (err) {
setError(err.message)
} finally {
@ -817,6 +876,10 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
recurringItems,
workCycles,
activeWorkCycle,
selectedWorkCycleId,
setSelectedWorkCycleId,
selectedWorkCycle,
selectedSprintActions,
sprintActions,
steeringSnapshot,
steeringSnapshotLoading,

View File

@ -5,6 +5,7 @@ import { LoadingState } from '../../components/LoadingState.jsx'
export function InitiativeInboxPage() {
const {
initiative,
initiativeId,
backlogItems,
roadmapItems,
workCycles,
@ -25,7 +26,7 @@ export function InitiativeInboxPage() {
return null
}
if (loading) {
if (loading && initiative === null) {
return <LoadingState message="Lade Eingang …" />
}
@ -36,6 +37,7 @@ export function InitiativeInboxPage() {
{error && <p className="error">{error}</p>}
<BacklogSection
items={backlogItems}
initiativeId={initiativeId}
roadmapItems={roadmapItems}
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}

View File

@ -5,14 +5,19 @@ import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { countActionsByWorkCycle } from '../../utils/workCycleActions.js'
function PlanSprintInner() {
const ops = useInitiativeOperations()
const {
initiativeId,
activeWorkCycle,
selectedWorkCycleId,
setSelectedWorkCycleId,
selectedWorkCycle,
selectedSprintActions,
workCycles,
sprintActions,
actions,
projects,
roadmapItems,
capabilities,
@ -40,17 +45,28 @@ function PlanSprintInner() {
handleCreateBlockerForAction,
} = ops
const actionCountByCycleId = useMemo(
() => countActionsByWorkCycle(actions, { hideDone }),
[actions, hideDone],
)
const createSprintAction = useMemo(
() => async (payload) => {
await handleCreateAction({
...payload,
work_cycle_id: payload.work_cycle_id || activeWorkCycle?.id || undefined,
work_cycle_id: payload.work_cycle_id || selectedWorkCycleId || activeWorkCycle?.id || undefined,
})
},
[handleCreateAction, activeWorkCycle?.id],
[handleCreateAction, selectedWorkCycleId, activeWorkCycle?.id],
)
if (loading) {
const sprintLead = selectedWorkCycle
? selectedWorkCycle.status === 'active'
? `Aktiver Sprint „${selectedWorkCycle.title}" — committet aus Eingang oder direkt hier.`
: `Geplanter Sprint „${selectedWorkCycle.title}" — Arbeitspakete aus dem Eingang erscheinen hier nach dem Commit.`
: 'Wähle einen Sprint in der Liste.'
if (loading && workCycles.length === 0) {
return <LoadingState message="Lade Sprint-Planung …" />
}
@ -60,16 +76,19 @@ function PlanSprintInner() {
<WorkCyclesPanel
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
selectedSprintId={selectedWorkCycleId}
actionCountByCycleId={actionCountByCycleId}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateWorkCycle}
onActivate={handleActivateWorkCycle}
onComplete={handleCompleteWorkCycle}
onSelectSprint={setSelectedWorkCycleId}
busy={formBusy}
/>
{activeWorkCycle && (
{selectedWorkCycleId && selectedWorkCycle && (
<InitiativeActionsHub
initiativeId={initiativeId}
actions={sprintActions}
actions={selectedSprintActions}
projects={projects}
roadmapItems={roadmapItems}
actionContextById={actionContextById}
@ -93,9 +112,9 @@ function PlanSprintInner() {
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
sectionTitle="Sprint-Backlog"
sectionLead="Arbeitspakete im aktiven Sprint — committet aus dem Product Backlog (Eingang) oder direkt hier."
activeWorkCycle={selectedWorkCycle}
sectionTitle={`Sprint-Backlog — ${selectedWorkCycle.title}`}
sectionLead={sprintLead}
/>
)}
</>

View File

@ -670,6 +670,43 @@
background: var(--jk-surface);
}
.work-cycle-list .work-cycle-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.work-cycle-row__select {
flex: 1;
text-align: left;
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
color: inherit;
}
.work-cycle-row__select:hover strong {
color: var(--jk-primary);
}
.work-cycle-row--selected {
box-shadow: inset 3px 0 0 var(--jk-primary);
}
.work-cycle-row__count {
margin-left: 6px;
font-size: 12px;
}
.work-cycle-row__actions {
display: flex;
flex-shrink: 0;
gap: 6px;
}
.backlog-sprint-setup {
margin-bottom: 16px;
padding: 12px;

View File

@ -0,0 +1,27 @@
/**
* Arbeitspakete eines Sprints (work_cycle) filtern.
* @param {Array<{ id: string, work_cycle_id?: string, status?: string }>} actions
* @param {string | null | undefined} cycleId
* @param {{ hideDone?: boolean }} options
*/
export function filterActionsForWorkCycle(actions, cycleId, { hideDone = true } = {}) {
if (!cycleId) return []
let list = Array.isArray(actions) ? actions : []
if (hideDone) {
list = list.filter((a) => a.status !== 'done' && a.status !== 'discarded')
}
return list.filter((a) => a.work_cycle_id === cycleId)
}
/**
* @param {Array<{ work_cycle_id?: string, status?: string }>} actions
*/
export function countActionsByWorkCycle(actions, { hideDone = true } = {}) {
const counts = {}
for (const action of actions || []) {
if (!action.work_cycle_id) continue
if (hideDone && (action.status === 'done' || action.status === 'discarded')) continue
counts[action.work_cycle_id] = (counts[action.work_cycle_id] || 0) + 1
}
return counts
}

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { countActionsByWorkCycle, filterActionsForWorkCycle } from './workCycleActions.js'
describe('workCycleActions', () => {
const actions = [
{ id: 'a1', work_cycle_id: 's1', status: 'open' },
{ id: 'a2', work_cycle_id: 's1', status: 'done' },
{ id: 'a3', work_cycle_id: 's2', status: 'ready' },
{ id: 'a4', status: 'open' },
]
it('filters actions for a sprint', () => {
expect(filterActionsForWorkCycle(actions, 's1').map((a) => a.id)).toEqual(['a1'])
expect(filterActionsForWorkCycle(actions, 's2').map((a) => a.id)).toEqual(['a3'])
expect(filterActionsForWorkCycle(actions, 'missing')).toEqual([])
})
it('counts actions per sprint', () => {
expect(countActionsByWorkCycle(actions)).toEqual({ s1: 1, s2: 1 })
})
})