From e221b5b09fba471b3f29bb7bfc3b02d113efe118 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 12 Jul 2026 19:38:39 +0200 Subject: [PATCH] fix: Sprint-Planung einsehbar und Eingang ohne Voll-Reload. Geplante Sprints in Plan anwaehlbar, lokales Update beim Commit, Sprint-Auswahl bleibt erhalten. Co-authored-by: Cursor --- frontend/src/components/BacklogSection.jsx | 17 ++- frontend/src/components/WorkCyclesPanel.jsx | 87 ++++++++------ .../context/InitiativeOperationsContext.jsx | 109 ++++++++++++++---- .../pages/initiative/InitiativeInboxPage.jsx | 4 +- frontend/src/pages/modes/PlanSprintPage.jsx | 37 ++++-- frontend/src/styles/program-chrome.css | 37 ++++++ frontend/src/utils/workCycleActions.js | 27 +++++ frontend/src/utils/workCycleActions.test.js | 21 ++++ 8 files changed, 269 insertions(+), 70 deletions(-) create mode 100644 frontend/src/utils/workCycleActions.js create mode 100644 frontend/src/utils/workCycleActions.test.js 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 ( +
    • - )} - {canManage && cycle.status !== 'active' && cycle.status !== 'reached' && ( - - )} -
    • - ))} +
      + {canManage && cycle.status === 'active' && typeof onComplete === 'function' && ( + + )} + {canManage && cycle.status !== 'active' && cycle.status !== 'reached' && ( + + )} +
      + + ) + })}
    )} 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 }) + }) +})