diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md index 1f379b8..e02eb97 100644 --- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md +++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md @@ -128,7 +128,7 @@ Phase H2 PM Work Modes AP1.9 ✓ 9a / → 9b–9e Phase H3 Plan Outline AP1.12 + AP1.10 ◐ 12a–d, 10c Phase I Gate-Graph AP1.13 + AP1.15 ◐ 13a/b, 15a–c Phase I2 Plan/Ist Snapshots AP1.14 ✓ -Phase I3 Execution-Plan AP1.16 ◐ 16a–b Code, 16c–d offen +Phase I3 Execution-Plan AP1.16 ◐ 16a–b ✓ / 16c UI / 16d offen Phase J Portfolio AP1.8 ◐ 8a ✓ / 8b deferred Phase K Archetyp-Steuerung AP2.0 ◐ 2.0a–c ✓ / → 2.0d–f Phase L Agent Interface AP1.7 ○ diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 149c16a..3eb417b 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -115,7 +115,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Gate Dependencies pflegen | ◐ | AP1.13b Detail | | Zielzustands-Designer | ◐ | AP1.15a–c; OR deferred | | Plan-Outline | ◐ | AP1.12a–d: Baum, Modal, Reorder, Actions; AP-Kanten AP1.16c offen | -| Execution-Plan (AP-Graph) | ✗ | 📄 AP1.16; Outline „Arbeit“, nicht Gate-Designer | +| Execution-Plan (AP-Graph) | ◐ | AP1.16c: Outline + Arbeit-Liste + Action-Detail | | Profil-Modal (Archetyp/EFS) | ◐ | AP1.10c | | Modal-Bearbeitung (Gates/Profil) | ◐ | viele Sektionen noch Inline-CRUD | | Admin-UI | ✗ | | diff --git a/frontend/src/api/executionPlan.js b/frontend/src/api/executionPlan.js new file mode 100644 index 0000000..d0f387e --- /dev/null +++ b/frontend/src/api/executionPlan.js @@ -0,0 +1,29 @@ +import { apiFetch } from './client.js' + +export function getInitiativeExecutionGraphState(initiativeId, { scopeRoadmapItemId } = {}) { + const params = new URLSearchParams() + if (scopeRoadmapItemId) { + params.set('scope_roadmap_item_id', scopeRoadmapItemId) + } + const query = params.toString() + const suffix = query ? `?${query}` : '' + return apiFetch(`/api/initiatives/${initiativeId}/execution/graph-state${suffix}`) +} + +export function listInitiativeActionDependencies(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/execution/dependencies`) +} + +export function createInitiativeActionDependency(initiativeId, payload) { + return apiFetch(`/api/initiatives/${initiativeId}/execution/dependencies`, { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function deleteInitiativeActionDependency(initiativeId, dependencyId) { + return apiFetch( + `/api/initiatives/${initiativeId}/execution/dependencies/${dependencyId}`, + { method: 'DELETE' }, + ) +} diff --git a/frontend/src/components/ActionDependenciesSection.jsx b/frontend/src/components/ActionDependenciesSection.jsx new file mode 100644 index 0000000..8829fed --- /dev/null +++ b/frontend/src/components/ActionDependenciesSection.jsx @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { + createInitiativeActionDependency, + deleteInitiativeActionDependency, + listInitiativeActionDependencies, +} from '../api/executionPlan.js' +import { actionPath } from '../utils/routes.js' +import { dependenciesForAction } from '../utils/executionGraph.js' +import { DependencyKindLabel, ExecutionFlowBadge } from './ExecutionFlowBadge.jsx' +import { EmptyState } from './EmptyState.jsx' + +const DEP_KIND_OPTIONS = [ + { value: 'requires', label: 'Benötigt (Vorgänger done)' }, + { value: 'blocks', label: 'Blockiert solange offen' }, + { value: 'relates', label: 'Bezug (kein Block)' }, +] + +export function ActionDependenciesSection({ + initiativeId, + action, + actions = [], + graphState = null, + canManage = false, +}) { + const [dependencies, setDependencies] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [predecessorId, setPredecessorId] = useState('') + const [dependencyKind, setDependencyKind] = useState('requires') + + const actionById = useMemo( + () => Object.fromEntries(actions.map((item) => [item.id, item])), + [actions], + ) + + const load = useCallback(async () => { + if (!initiativeId) return + setLoading(true) + setError(null) + try { + const rows = await listInitiativeActionDependencies(initiativeId) + setDependencies(Array.isArray(rows) ? rows : []) + } catch (err) { + setError(err.message) + setDependencies([]) + } finally { + setLoading(false) + } + }, [initiativeId]) + + useEffect(() => { + load() + }, [load]) + + const { predecessors, successors } = useMemo( + () => dependenciesForAction(dependencies, action.id), + [dependencies, action.id], + ) + + const candidatePredecessors = useMemo(() => { + const existingPreds = new Set(predecessors.map((dep) => dep.predecessor_action_id)) + return actions.filter( + (item) => + item.id !== action.id && + !existingPreds.has(item.id) && + item.status !== 'discarded', + ) + }, [actions, action.id, predecessors]) + + const executionMeta = graphState?.items?.[action.id] || null + + async function handleAdd(e) { + e.preventDefault() + if (!predecessorId || !canManage) return + setBusy(true) + setError(null) + try { + await createInitiativeActionDependency(initiativeId, { + predecessor_action_id: predecessorId, + successor_action_id: action.id, + dependency_kind: dependencyKind, + }) + setPredecessorId('') + setDependencyKind('requires') + await load() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + async function handleRemove(dependencyId) { + if (!canManage) return + setBusy(true) + setError(null) + try { + await deleteInitiativeActionDependency(initiativeId, dependencyId) + await load() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + return ( +
+
+
+

Durchführungsplan

+

+ Vorgänger und Nachfolger — getrennt vom Zielzustands-Graph. +

+
+ +
+ + {error &&

{error}

} + {loading &&

Lade Abhängigkeiten …

} + + {!loading && ( + <> +
+

Vorgänger

+ {predecessors.length === 0 ? ( + + ) : ( +
    + {predecessors.map((dep) => { + const pred = actionById[dep.predecessor_action_id] + return ( +
  • +
    + + {pred?.title || dep.predecessor_action_id.slice(0, 8)} + +

    + +

    +
    + {canManage && ( + + )} +
  • + ) + })} +
+ )} +
+ +
+

Nachfolger

+ {successors.length === 0 ? ( +

Keine Nachfolger.

+ ) : ( + + )} +
+ + {canManage && candidatePredecessors.length > 0 && ( +
+

Vorgänger hinzufügen

+ + +
+ +
+
+ )} + + )} +
+ ) +} diff --git a/frontend/src/components/ExecutionFlowBadge.jsx b/frontend/src/components/ExecutionFlowBadge.jsx new file mode 100644 index 0000000..264a5d4 --- /dev/null +++ b/frontend/src/components/ExecutionFlowBadge.jsx @@ -0,0 +1,31 @@ +const KIND_LABELS = { + requires: 'benötigt', + blocks: 'blockiert', + relates: 'bezogen', +} + +export function ExecutionFlowBadge({ meta, actionStatus }) { + if (!meta) return null + if (actionStatus === 'done' || actionStatus === 'discarded') { + return null + } + if (meta.blocked) { + return ( + + Wartet + + ) + } + if (meta.ready) { + return ( + + Bereit + + ) + } + return null +} + +export function DependencyKindLabel({ kind }) { + return {KIND_LABELS[kind] || kind} +} diff --git a/frontend/src/components/PlanActionsSection.jsx b/frontend/src/components/PlanActionsSection.jsx index 6379b8f..92d7d56 100644 --- a/frontend/src/components/PlanActionsSection.jsx +++ b/frontend/src/components/PlanActionsSection.jsx @@ -9,6 +9,12 @@ import { ActionTaskPanel } from './ActionTaskPanel.jsx' import { gateTitleById } from './GateSelect.jsx' import { buildProjectPath, collectProjectSubtreeIds } from '../utils/projectTree.js' import { actionPath } from '../utils/routes.js' +import { + formatBlockedByTitles, + getActionExecutionMeta, + sortActionsForExecutionPlan, +} from '../utils/executionGraph.js' +import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx' function formatProjectContext(projects, projectId) { if (!projectId) return 'Direkt am Vorhaben' @@ -31,6 +37,7 @@ export function PlanActionsSection({ actorsUsedFallback, onReloadActors, busy, + executionGraph = null, }) { const [showCreate, setShowCreate] = useState(false) const [expandedActions, setExpandedActions] = useState(() => new Set()) @@ -45,16 +52,7 @@ export function PlanActionsSection({ list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id)) } - return list.sort((a, b) => { - const projectA = a.project_id || '' - const projectB = b.project_id || '' - if (projectA !== projectB) { - if (!projectA) return -1 - if (!projectB) return 1 - return projectA.localeCompare(projectB) - } - return (a.title || '').localeCompare(b.title || '') - }) + return sortActionsForExecutionPlan(list) }, [actions, hideDone, scopeProjectId, projects]) async function handleCreate(payload) { @@ -112,13 +110,25 @@ export function PlanActionsSection({ )} setShowCreate(false)} size="lg"> diff --git a/frontend/src/components/PlanOutlineNav.jsx b/frontend/src/components/PlanOutlineNav.jsx index 404841c..fe24c54 100644 --- a/frontend/src/components/PlanOutlineNav.jsx +++ b/frontend/src/components/PlanOutlineNav.jsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import { listInitiativeProjects } from '../api/projects.js' import { listInitiativeActions } from '../api/initiatives.js' import { listInitiativeBacklog } from '../api/backlog.js' +import { getInitiativeExecutionGraphState } from '../api/executionPlan.js' import { useProgramScope } from '../context/ProgramScopeContext.jsx' import { buildProjectsByParent } from '../utils/projectTree.js' import { projectPath } from '../utils/routes.js' @@ -58,6 +59,7 @@ export function PlanOutlineNav() { const [counts, setCounts] = useState({ inbox: null, work: null }) const [projects, setProjects] = useState([]) const [openActions, setOpenActions] = useState([]) + const [executionGraph, setExecutionGraph] = useState(null) const [structureOpen, setStructureOpen] = useState( activeKey === 'structure' || Boolean(activeProjectId), ) @@ -100,12 +102,14 @@ export function PlanOutlineNav() { Promise.all([ listInitiativeBacklog(initiativeId).catch(() => []), listInitiativeActions(initiativeId).catch(() => []), - ]).then(([backlog, actions]) => { + getInitiativeExecutionGraphState(initiativeId).catch(() => null), + ]).then(([backlog, actions, graphState]) => { if (cancelled) return const open = actions.filter( (action) => action.status !== 'done' && action.status !== 'discarded', ) setOpenActions(open) + setExecutionGraph(graphState) setCounts({ inbox: backlog.filter((item) => item.status !== 'converted').length, work: open.length, @@ -222,6 +226,7 @@ export function PlanOutlineNav() { openActions={openActions} activeActionId={activeActionId} enabled={workOpen} + executionGraph={executionGraph} /> )} diff --git a/frontend/src/components/PlanOutlineWorkTree.jsx b/frontend/src/components/PlanOutlineWorkTree.jsx index be085c1..3aee374 100644 --- a/frontend/src/components/PlanOutlineWorkTree.jsx +++ b/frontend/src/components/PlanOutlineWorkTree.jsx @@ -3,6 +3,8 @@ import { NavLink } from 'react-router-dom' import { listActionTasks } from '../api/tasks.js' import { actionPath } from '../utils/routes.js' import { buildTasksByParent, countOpenTasks, MAX_TASK_UI_DEPTH } from '../utils/taskTree.js' +import { getActionExecutionMeta } from '../utils/executionGraph.js' +import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx' function actionTasksHref(actionId) { return `${actionPath(actionId)}#tasks` @@ -80,7 +82,7 @@ function PlanOutlineTaskTree({ ) } -function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded }) { +function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded, executionGraph }) { const [actionOpen, setActionOpen] = useState(defaultExpanded) const [expandedTaskIds, setExpandedTaskIds] = useState(() => new Set()) @@ -101,6 +103,8 @@ function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded }) } + const execMeta = getActionExecutionMeta(executionGraph, action.id) + return (
  • @@ -129,6 +133,7 @@ function PlanOutlineActionNode({ action, tasks, activeActionId, defaultExpanded ({openTaskCount}) )} +
    {hasOpenTasks && actionOpen && ( openActions.map((action) => action.id).join(','), @@ -181,6 +186,7 @@ export function PlanOutlineWorkTree({ openActions, activeActionId, enabled }) { tasks={tasksByAction[action.id] || []} activeActionId={activeActionId} defaultExpanded={activeActionId === action.id} + executionGraph={executionGraph} /> ))} diff --git a/frontend/src/components/PlanningDebtBanner.jsx b/frontend/src/components/PlanningDebtBanner.jsx new file mode 100644 index 0000000..16b7c08 --- /dev/null +++ b/frontend/src/components/PlanningDebtBanner.jsx @@ -0,0 +1,27 @@ +import { Link } from 'react-router-dom' + +export function PlanningDebtBanner({ planningDebt, hrefWithScope }) { + if (!planningDebt?.length || !hrefWithScope) { + return null + } + + return ( +
    +

    Planung ausstehend

    +
      + {planningDebt.map((item) => ( +
    • + {item.title || 'Zielzustand'} + — {item.message || 'Durchführungsplan fehlt'} +
    • + ))} +
    +

    + Lege Arbeitspakete unter dem aktiven Gate an —{' '} + Zielzustände + {' · '} + Arbeit +

    +
    + ) +} diff --git a/frontend/src/pages/ActionDetailPage.jsx b/frontend/src/pages/ActionDetailPage.jsx index d6d3fb5..037938f 100644 --- a/frontend/src/pages/ActionDetailPage.jsx +++ b/frontend/src/pages/ActionDetailPage.jsx @@ -1,12 +1,17 @@ import { useCallback, useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { getAction } from '../api/actions.js' -import { getInitiativeSteeringSnapshot } from '../api/initiatives.js' +import { + getInitiativeSteeringSnapshot, + listInitiativeActions, +} from '../api/initiatives.js' +import { getInitiativeExecutionGraphState } from '../api/executionPlan.js' import { listInitiativeProjects } from '../api/projects.js' import { listInitiativeRoadmapItems } from '../api/roadmap.js' import { updateAction, setActionAssignments } from '../api/actions.js' import { createInitiativeBlocker } from '../api/blockers.js' import { ActionHubCard } from '../components/ActionHubCard.jsx' +import { ActionDependenciesSection } from '../components/ActionDependenciesSection.jsx' import { ActionForm } from '../components/ActionForm.jsx' import { TasksSection } from '../components/TasksSection.jsx' import { ErrorState } from '../components/ErrorState.jsx' @@ -32,6 +37,8 @@ function ActionDetailBody({ actorsState, projects = [], roadmapItems = [], + allActions = [], + executionGraph = null, }) { if (editing) { return ( @@ -78,6 +85,13 @@ function ActionDetailBody({ canManage={capabilities.has('kairo.action.manage')} roadmapItems={roadmapItems} /> + ) } @@ -90,6 +104,8 @@ function ActionDetailStandalone() { const [action, setAction] = useState(null) const [projects, setProjects] = useState([]) const [roadmapItems, setRoadmapItems] = useState([]) + const [allActions, setAllActions] = useState([]) + const [executionGraph, setExecutionGraph] = useState(null) const [context, setContext] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -110,13 +126,17 @@ function ActionDetailStandalone() { href: actionPath(actionData.id), initiativeId: actionData.initiative_id, }) - const [snap, projectData, roadmapData] = await Promise.all([ + const [snap, projectData, roadmapData, actionList, graphState] = await Promise.all([ getInitiativeSteeringSnapshot(actionData.initiative_id), listInitiativeProjects(actionData.initiative_id).catch(() => []), listInitiativeRoadmapItems(actionData.initiative_id).catch(() => []), + listInitiativeActions(actionData.initiative_id).catch(() => []), + getInitiativeExecutionGraphState(actionData.initiative_id).catch(() => null), ]) setProjects(projectData) setRoadmapItems(roadmapData) + setAllActions(Array.isArray(actionList) ? actionList : []) + setExecutionGraph(graphState) setContext((snap.actions || []).find((a) => a.id === actionId) || null) } catch (err) { setError(err.message) @@ -232,6 +252,8 @@ function ActionDetailStandalone() { actorsState={actorsState} projects={projects} roadmapItems={roadmapItems} + allActions={allActions} + executionGraph={executionGraph} /> @@ -244,6 +266,25 @@ function ActionDetailNested() { const action = ops.actions.find((a) => a.id === actionId) const context = ops.actionContextById[actionId] const [editing, setEditing] = useState(false) + const [executionGraph, setExecutionGraph] = useState(null) + + useEffect(() => { + if (!ops.initiativeId) { + setExecutionGraph(null) + return undefined + } + let cancelled = false + getInitiativeExecutionGraphState(ops.initiativeId) + .then((state) => { + if (!cancelled) setExecutionGraph(state) + }) + .catch(() => { + if (!cancelled) setExecutionGraph(null) + }) + return () => { + cancelled = true + } + }, [ops.initiativeId, ops.actions.length]) if (!action) { return @@ -274,6 +315,8 @@ function ActionDetailNested() { }} projects={ops.projects} roadmapItems={ops.roadmapItems} + allActions={ops.actions} + executionGraph={executionGraph} /> ) diff --git a/frontend/src/pages/modes/PlanWorkPage.jsx b/frontend/src/pages/modes/PlanWorkPage.jsx index abd88f6..a6dc21c 100644 --- a/frontend/src/pages/modes/PlanWorkPage.jsx +++ b/frontend/src/pages/modes/PlanWorkPage.jsx @@ -1,13 +1,17 @@ import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' import { PlanActionsSection } from '../../components/PlanActionsSection.jsx' +import { PlanningDebtBanner } from '../../components/PlanningDebtBanner.jsx' import { LoadingState } from '../../components/LoadingState.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useProgramScope } from '../../context/ProgramScopeContext.jsx' +import { useEffect, useState } from 'react' +import { getInitiativeExecutionGraphState } from '../../api/executionPlan.js' function PlanWorkInner() { const ops = useInitiativeOperations() - const { projectId: scopeProjectId } = useProgramScope() + const { initiativeId, projectId: scopeProjectId, hrefWithScope } = useProgramScope() + const [executionGraph, setExecutionGraph] = useState(null) const { actions, projects, @@ -26,6 +30,24 @@ function PlanWorkInner() { handleCreateAction, } = ops + useEffect(() => { + if (!initiativeId) { + setExecutionGraph(null) + return undefined + } + let cancelled = false + getInitiativeExecutionGraphState(initiativeId) + .then((state) => { + if (!cancelled) setExecutionGraph(state) + }) + .catch(() => { + if (!cancelled) setExecutionGraph(null) + }) + return () => { + cancelled = true + } + }, [initiativeId, actions.length]) + if (loading) { return } @@ -33,6 +55,10 @@ function PlanWorkInner() { return ( <> {error &&

    {error}

    } + ) diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css index 02d426b..d1bd38f 100644 --- a/frontend/src/styles/program-chrome.css +++ b/frontend/src/styles/program-chrome.css @@ -486,6 +486,70 @@ gap: 12px; } +.execution-flow-badge { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.execution-flow-badge--ready { + background: color-mix(in srgb, var(--jk-success, #059669) 15%, transparent); + color: var(--jk-success, #059669); +} + +.execution-flow-badge--blocked { + background: color-mix(in srgb, var(--jk-warning, #d97706) 15%, transparent); + color: var(--jk-warning, #d97706); +} + +.execution-flow-badge--planning { + background: color-mix(in srgb, var(--jk-primary, #2563eb) 12%, transparent); + color: var(--jk-primary, #2563eb); +} + +.analysis-split__nav-row .execution-flow-badge { + margin-left: 4px; + flex-shrink: 0; +} + +.planning-debt-banner { + margin-bottom: 16px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--jk-warning, #d97706) 35%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--jk-warning, #d97706) 8%, transparent); +} + +.planning-debt-banner__title { + margin: 0 0 8px; + font-weight: 600; +} + +.planning-debt-banner__list { + margin: 0 0 8px; + padding-left: 18px; +} + +.action-dependencies-section { + margin-top: 16px; +} + +.action-dependencies-section__block + .action-dependencies-section__block { + margin-top: 16px; +} + +.action-dependencies-section__subtitle { + margin: 0 0 8px; + font-size: 14px; +} + +.action-dependencies-section__form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--jk-border-subtle, #e5e7eb); +} + .action-task-panel { margin-top: 12px; padding-top: 12px; diff --git a/frontend/src/utils/executionGraph.js b/frontend/src/utils/executionGraph.js new file mode 100644 index 0000000..246e1b2 --- /dev/null +++ b/frontend/src/utils/executionGraph.js @@ -0,0 +1,46 @@ +/** @typedef {import('../api/executionPlan.js').*} ExecutionPlanApi */ + +/** + * @param {Record|null|undefined} graphState + * @param {string} actionId + */ +export function getActionExecutionMeta(graphState, actionId) { + if (!graphState?.items || !actionId) return null + return graphState.items[actionId] || null +} + +/** + * @param {Array<{ id: string, title?: string }>} actions + * @param {string[]} blockedByIds + */ +export function formatBlockedByTitles(actions, blockedByIds) { + if (!blockedByIds?.length) return '' + const byId = Object.fromEntries(actions.map((a) => [a.id, a])) + return blockedByIds + .map((id) => byId[id]?.title || id.slice(0, 8)) + .join(', ') +} + +/** + * Sort actions for Durchführungsplan display (sort_order, then title). + * @param {Array<{ sort_order?: number, title?: string }>} actions + */ +export function sortActionsForExecutionPlan(actions) { + return [...actions].sort((a, b) => { + const orderDiff = (a.sort_order ?? 0) - (b.sort_order ?? 0) + if (orderDiff !== 0) return orderDiff + return (a.title || '').localeCompare(b.title || '', 'de') + }) +} + +/** + * @param {Array<{ successor_action_id: string, predecessor_action_id: string, dependency_kind?: string, id: string }>} dependencies + * @param {string} actionId + */ +export function dependenciesForAction(dependencies, actionId) { + const list = Array.isArray(dependencies) ? dependencies : [] + return { + predecessors: list.filter((dep) => dep.successor_action_id === actionId), + successors: list.filter((dep) => dep.predecessor_action_id === actionId), + } +} diff --git a/frontend/src/utils/executionGraph.test.js b/frontend/src/utils/executionGraph.test.js new file mode 100644 index 0000000..db4cfd2 --- /dev/null +++ b/frontend/src/utils/executionGraph.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + dependenciesForAction, + formatBlockedByTitles, + getActionExecutionMeta, + sortActionsForExecutionPlan, +} from './executionGraph.js' + +describe('executionGraph utils', () => { + it('reads action meta from graph state', () => { + const state = { + items: { + a1: { ready: true, blocked: false, blocked_by: [] }, + }, + } + expect(getActionExecutionMeta(state, 'a1')?.ready).toBe(true) + expect(getActionExecutionMeta(state, 'missing')).toBeNull() + }) + + it('formats blocked-by titles', () => { + const actions = [{ id: 'x', title: 'Foundation' }] + expect(formatBlockedByTitles(actions, ['x'])).toBe('Foundation') + }) + + it('sorts by sort_order', () => { + const sorted = sortActionsForExecutionPlan([ + { id: '2', title: 'B', sort_order: 2 }, + { id: '1', title: 'A', sort_order: 1 }, + ]) + expect(sorted.map((a) => a.id)).toEqual(['1', '2']) + }) + + it('splits predecessors and successors', () => { + const deps = [ + { + id: 'd1', + predecessor_action_id: 'p1', + successor_action_id: 's1', + dependency_kind: 'requires', + }, + ] + expect(dependenciesForAction(deps, 's1').predecessors).toHaveLength(1) + expect(dependenciesForAction(deps, 'p1').successors).toHaveLength(1) + }) +})