diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx
index 7fa6838..0377fd5 100644
--- a/frontend/src/components/BacklogSection.jsx
+++ b/frontend/src/components/BacklogSection.jsx
@@ -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
diff --git a/frontend/src/components/WorkCyclesPanel.jsx b/frontend/src/components/WorkCyclesPanel.jsx
index f54b93d..64705e2 100644
--- a/frontend/src/components/WorkCyclesPanel.jsx
+++ b/frontend/src/components/WorkCyclesPanel.jsx
@@ -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({
Sprint
- 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.
@@ -46,12 +49,9 @@ export function WorkCyclesPanel({
{activeWorkCycle.goal_description && (
— {activeWorkCycle.goal_description}
)}
- {typeof activeWorkCycle.open_action_count === 'number' && (
- ({activeWorkCycle.open_action_count} offene APs)
- )}
) : (
- Kein aktiver Sprint — committete Arbeitspakete gelten als Product-Ist.
+ Kein aktiver Sprint — du kannst trotzdem geplante Sprints einsehen.
)}
{canManage && (
@@ -91,38 +91,57 @@ export function WorkCyclesPanel({
{workCycles.length === 0 ? (
) : (
-
- {workCycles.map((cycle) => (
-
-
-
{cycle.title} {' '}
-
- {cycle.goal_description && (
-
{cycle.goal_description}
- )}
-
- {canManage && cycle.status === 'active' && typeof onComplete === 'function' && (
+
+ {workCycles.map((cycle) => {
+ const isSelected = selectedSprintId === cycle.id
+ const apCount = actionCountByCycleId[cycle.id] || 0
+ return (
+
onComplete(cycle.id)}
+ className="work-cycle-row__select"
+ onClick={() => onSelectSprint?.(cycle.id)}
>
- Abschließen
+ {cycle.title} {' '}
+
+
+ {apCount} AP{apCount === 1 ? '' : 's'}
+
+ {cycle.goal_description && (
+ {cycle.goal_description}
+ )}
- )}
- {canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
- onActivate(cycle.id)}
- >
- Aktivieren
-
- )}
-
- ))}
+
+ {canManage && cycle.status === 'active' && typeof onComplete === 'function' && (
+ onComplete(cycle.id)}
+ >
+ Abschließen
+
+ )}
+ {canManage && cycle.status !== 'active' && cycle.status !== 'reached' && (
+ onActivate(cycle.id)}
+ >
+ Aktivieren
+
+ )}
+
+
+ )
+ })}
)}
diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx
index 4920944..21048b6 100644
--- a/frontend/src/context/InitiativeOperationsContext.jsx
+++ b/frontend/src/context/InitiativeOperationsContext.jsx
@@ -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,
diff --git a/frontend/src/pages/initiative/InitiativeInboxPage.jsx b/frontend/src/pages/initiative/InitiativeInboxPage.jsx
index 9d9425d..0c98d31 100644
--- a/frontend/src/pages/initiative/InitiativeInboxPage.jsx
+++ b/frontend/src/pages/initiative/InitiativeInboxPage.jsx
@@ -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
}
@@ -36,6 +37,7 @@ export function InitiativeInboxPage() {
{error && {error}
}
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
}
@@ -60,16 +76,19 @@ function PlanSprintInner() {
- {activeWorkCycle && (
+ {selectedWorkCycleId && selectedWorkCycle && (
)}
>
diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css
index 267cd00..eab49bb 100644
--- a/frontend/src/styles/program-chrome.css
+++ b/frontend/src/styles/program-chrome.css
@@ -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;
diff --git a/frontend/src/utils/workCycleActions.js b/frontend/src/utils/workCycleActions.js
new file mode 100644
index 0000000..c075cd4
--- /dev/null
+++ b/frontend/src/utils/workCycleActions.js
@@ -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
+}
diff --git a/frontend/src/utils/workCycleActions.test.js b/frontend/src/utils/workCycleActions.test.js
new file mode 100644
index 0000000..efc4127
--- /dev/null
+++ b/frontend/src/utils/workCycleActions.test.js
@@ -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 })
+ })
+})