From 1b55d8558c98b5857cff1ec658694a7688b5cf3e Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 25 Jul 2026 10:26:56 +0200 Subject: [PATCH] AP2.3c: Slice-bewusstes Laden und hoisted InitiativeOperationsProvider. Co-authored-by: Cursor --- frontend/src/App.jsx | 89 ++++++++++++-- frontend/src/components/PlanOutlineNav.jsx | 109 ++++++----------- .../context/InitiativeOperationsContext.jsx | 114 +++++++++++------- .../src/layout/ScopedInitiativeProvider.jsx | 17 +-- .../src/layout/ScopedOperationsBridge.jsx | 12 ++ .../src/registry/operatingSliceLoaders.js | 107 ++++++++++++++++ 6 files changed, 308 insertions(+), 140 deletions(-) create mode 100644 frontend/src/layout/ScopedOperationsBridge.jsx create mode 100644 frontend/src/registry/operatingSliceLoaders.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 74cb635..82da70b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { SessionProvider, useSession } from './context/SessionContext.jsx' import { ProgramScopeProvider } from './context/ProgramScopeContext.jsx' +import { ScopedOperationsBridge } from './layout/ScopedOperationsBridge.jsx' import { AppLayout } from './layout/AppLayout.jsx' import { AuthPanel } from './pages/AuthPanel.jsx' import { ActionDetailPage } from './pages/ActionDetailPage.jsx' @@ -16,6 +17,7 @@ import { ControlIndexRedirect, MODE_ROUTE_COMPONENTS, } from './registry/viewRegistry.js' +import { OperatingRouteGate } from './registry/OperatingRouteGate.jsx' import { WorkspaceRedirect, MyActionsRedirect, @@ -54,35 +56,99 @@ function AppRoutes() { return ( - - }> + + + }> {topLevelViews.map((view) => ( } /> ))} }> } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> }> } /> } /> } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> }> } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> } /> @@ -109,6 +175,7 @@ function AppRoutes() { } /> + ) } diff --git a/frontend/src/components/PlanOutlineNav.jsx b/frontend/src/components/PlanOutlineNav.jsx index afbcf93..6203dd7 100644 --- a/frontend/src/components/PlanOutlineNav.jsx +++ b/frontend/src/components/PlanOutlineNav.jsx @@ -1,10 +1,9 @@ import { NavLink, useLocation } from 'react-router-dom' 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 { useOptionalInitiativeOperations } from '../context/InitiativeOperationsContext.jsx' +import { useMethodUiContext } from '../hooks/useMethodUiContext.js' import { buildProjectsByParent } from '../utils/projectTree.js' import { projectPath } from '../utils/routes.js' import { @@ -12,9 +11,7 @@ import { resolvePlanOutlineProjectId, resolvePlanOutlineActionId, } from '../plan/planOutlineNodes.js' -import { resolvePlanOutlineNodes } from '../config/methodUiDefaults.js' -import { getInitiative } from '../api/initiatives.js' -import { getActiveWorkCycle } from '../api/workCycles.js' +import { resolvePlanOutlineNodesFromProfile } from '../registry/resolveOperatingProfile.js' import { PlanOutlineWorkTree } from './PlanOutlineWorkTree.jsx' function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) { @@ -55,15 +52,12 @@ function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 export function PlanOutlineNav() { const location = useLocation() const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope() + const { operatingContext, hasActiveSprint } = useMethodUiContext() + const ops = useOptionalInitiativeOperations() const activeKey = resolvePlanOutlineActiveKey(location.pathname) const activeProjectId = resolvePlanOutlineProjectId(location.pathname) const activeActionId = resolvePlanOutlineActionId(location.pathname) - const [counts, setCounts] = useState({ inbox: null, work: null }) - const [projects, setProjects] = useState([]) - const [openActions, setOpenActions] = useState([]) const [executionGraph, setExecutionGraph] = useState(null) - const [archetypeKey, setArchetypeKey] = useState(null) - const [hasActiveSprint, setHasActiveSprint] = useState(false) const [structureOpen, setStructureOpen] = useState( activeKey === 'structure' || Boolean(activeProjectId), ) @@ -71,6 +65,29 @@ export function PlanOutlineNav() { activeKey === 'work' || Boolean(activeActionId), ) + const contextReady = Boolean(initiativeId && ops?.initiativeId === initiativeId) + const projects = contextReady ? ops.projects : [] + const backlogItems = contextReady ? ops.backlogItems : [] + const actions = contextReady ? ops.actions : [] + + const openActions = useMemo( + () => + actions.filter( + (action) => action.status !== 'done' && action.status !== 'discarded', + ), + [actions], + ) + + const counts = useMemo( + () => ({ + inbox: contextReady + ? backlogItems.filter((item) => item.status !== 'converted').length + : null, + work: contextReady ? openActions.length : null, + }), + [contextReady, backlogItems, openActions], + ) + useEffect(() => { setStructureOpen(activeKey === 'structure' || Boolean(activeProjectId)) }, [activeKey, activeProjectId]) @@ -79,85 +96,31 @@ export function PlanOutlineNav() { setWorkOpen(activeKey === 'work' || Boolean(activeActionId)) }, [activeKey, activeActionId]) - useEffect(() => { - if (!initiativeId) { - setArchetypeKey(null) - setHasActiveSprint(false) - return undefined - } - let cancelled = false - Promise.all([ - getInitiative(initiativeId), - getActiveWorkCycle(initiativeId).catch(() => null), - ]) - .then(([initiative, activeCycle]) => { - if (cancelled) return - setArchetypeKey(initiative?.archetype_key || null) - setHasActiveSprint(Boolean(activeCycle?.id)) - }) - .catch(() => { - if (!cancelled) { - setArchetypeKey(null) - setHasActiveSprint(false) - } - }) - return () => { - cancelled = true - } - }, [initiativeId]) - const outlineNodes = useMemo( () => - resolvePlanOutlineNodes({ - archetypeKey, + resolvePlanOutlineNodesFromProfile({ + operatingContext, hasActiveSprint, }), - [archetypeKey, hasActiveSprint], + [operatingContext, hasActiveSprint], ) useEffect(() => { if (!initiativeId) { - setProjects([]) + setExecutionGraph(null) return undefined } let cancelled = false - listInitiativeProjects(initiativeId) - .then((items) => { - if (!cancelled) setProjects(Array.isArray(items) ? items : []) + getInitiativeExecutionGraphState(initiativeId) + .then((graphState) => { + if (!cancelled) setExecutionGraph(graphState) }) .catch(() => { - if (!cancelled) setProjects([]) + if (!cancelled) setExecutionGraph(null) }) return () => { cancelled = true } - }, [initiativeId]) - - useEffect(() => { - if (!initiativeId) { - setCounts({ inbox: null, work: null }) - return undefined - } - let cancelled = false - Promise.all([ - listInitiativeBacklog(initiativeId).catch(() => []), - listInitiativeActions(initiativeId).catch(() => []), - 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, - }) - }) - return () => { - cancelled = true - } }, [initiativeId, location.pathname]) function nodeLabel(node) { diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index 1ff54da..ecb754d 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -10,6 +10,7 @@ import { useParams } from 'react-router-dom' import { getInitiative, getInitiativeSteeringSnapshot, + getInitiativeOperatingContext, listInitiativeActions, createInitiativeAction, updateInitiative, @@ -86,6 +87,10 @@ import { useOptionalProgramScope } from './ProgramScopeContext.jsx' import { collectProjectSubtreeIds } from '../utils/projectTree.js' import { filterActionsForWorkCycle } from '../utils/workCycleActions.js' import { listFeatureParentActions } from '../utils/actionReferences.js' +import { + applySliceResults, + loadOperatingSlices, +} from '../registry/operatingSliceLoaders.js' const InitiativeOperationsContext = createContext(null) @@ -114,6 +119,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ const [steeringSnapshotLoading, setSteeringSnapshotLoading] = useState(false) const [steeringSnapshotError, setSteeringSnapshotError] = useState(null) const [steeringMethods, setSteeringMethods] = useState([]) + const [operatingContext, setOperatingContext] = useState(null) const [methodBusy, setMethodBusy] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -123,62 +129,74 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ const [hideDone, setHideDone] = useState(true) const [selectedWorkCycleId, setSelectedWorkCycleId] = useState('') - const load = useCallback(async ({ silent = false } = {}) => { + const sliceSetters = useMemo( + () => ({ + setInitiative, + setActions, + setBlockers, + setBacklogItems, + setRoadmapItems, + setProjects, + setEvidenceItems, + setDecisions, + setReviews, + setRecurringItems, + setWorkCycles, + setActiveWorkCycle, + setSteeringMethods, + setSteeringSnapshot, + }), + [], + ) + + const reloadSlices = useCallback( + async (sliceKeys) => { + if (!id || !sliceKeys?.length) return + const results = await loadOperatingSlices(id, sliceKeys) + applySliceResults(results, sliceSetters) + }, + [id, sliceSetters], + ) + + const load = useCallback(async ({ silent = false, sliceKeys = null } = {}) => { if (sessionLoading || !id) return if (!silent) setLoading(true) setError(null) try { - const [initData, actionData] = await Promise.all([ - getInitiative(id), - listInitiativeActions(id), - ]) - setInitiative(initData) - setActions(actionData) - - const loads = [] const canReadOm = capabilities.has('kairo.initiative.read') || capabilities.has('kairo.blocker.read') - if (canReadOm) { - loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([]))) - loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([]))) - loads.push(listInitiativeRoadmapItems(id).then(setRoadmapItems).catch(() => setRoadmapItems([]))) - loads.push(listInitiativeProjects(id).then(setProjects).catch(() => setProjects([]))) - loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([]))) - loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([]))) - loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([]))) - loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([]))) - loads.push(listInitiativeWorkCycles(id).then(setWorkCycles).catch(() => setWorkCycles([]))) - loads.push( - getActiveWorkCycle(id) - .then((item) => setActiveWorkCycle(item || null)) - .catch(() => setActiveWorkCycle(null)) - ) - loads.push( - listSteeringMethods().then(setSteeringMethods).catch(() => setSteeringMethods([])) - ) - loads.push( - (async () => { - setSteeringSnapshotLoading(true) - setSteeringSnapshotError(null) - try { - setSteeringSnapshot(await getInitiativeSteeringSnapshot(id)) - } catch (err) { - setSteeringSnapshot(null) - setSteeringSnapshotError(err.message || 'Snapshot fehlgeschlagen') - } finally { - setSteeringSnapshotLoading(false) - } - })() - ) + + let opContext = null + if (!sliceKeys) { + opContext = await getInitiativeOperatingContext(id).catch(() => null) + setOperatingContext(opContext) + } + + const omSlices = canReadOm ? opContext?.data_slices || [] : [] + const keysToLoad = sliceKeys || ['initiative', ...omSlices] + + if (keysToLoad.includes('steering_snapshot')) { + setSteeringSnapshotLoading(true) + setSteeringSnapshotError(null) + } + + const results = await loadOperatingSlices(id, keysToLoad) + applySliceResults(results, sliceSetters) + + if (keysToLoad.includes('steering_snapshot') && results.steering_snapshot == null) { + setSteeringSnapshotError('Snapshot fehlgeschlagen') + } + if (keysToLoad.includes('steering_snapshot')) { + setSteeringSnapshotLoading(false) } - await Promise.all(loads) } catch (err) { setError(err.message) + setSteeringSnapshotLoading(false) } finally { setLoading(false) } - }, [id, capabilities, sessionLoading]) + }, [id, capabilities, sessionLoading, sliceSetters]) useEffect(() => { load() @@ -380,6 +398,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ try { const updated = await updateAction(action.id, { work_cycle_id: targetCycleId }) setActions((prev) => prev.map((a) => (a.id === action.id ? updated : a))) + await reloadSlices(['work_cycles']) } catch (err) { setError(err.message) } finally { @@ -975,6 +994,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ steeringSnapshotLoading, steeringSnapshotError, steeringMethods, + operatingContext, + dataSlices: operatingContext?.data_slices || [], methodBusy, loading, error, @@ -993,6 +1014,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ actorsUsedFallback: actorsState.usedFallback, reloadActors: actorsState.reload, reload: load, + reloadSlices, handleMethodChange, handleMethodProfileChange, handleCreateAction, @@ -1053,8 +1075,12 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ ) } +export function useOptionalInitiativeOperations() { + return useContext(InitiativeOperationsContext) +} + export function useInitiativeOperations() { - const ctx = useContext(InitiativeOperationsContext) + const ctx = useOptionalInitiativeOperations() if (!ctx) { throw new Error('useInitiativeOperations must be used within InitiativeOperationsProvider') } diff --git a/frontend/src/layout/ScopedInitiativeProvider.jsx b/frontend/src/layout/ScopedInitiativeProvider.jsx index b4dca62..355f515 100644 --- a/frontend/src/layout/ScopedInitiativeProvider.jsx +++ b/frontend/src/layout/ScopedInitiativeProvider.jsx @@ -1,13 +1,6 @@ -import { InitiativeOperationsProvider } from '../context/InitiativeOperationsContext.jsx' -import { useProgramScope } from '../context/ProgramScopeContext.jsx' - -export function ScopedInitiativeProvider({ initiativeId: initiativeIdProp, children }) { - const { initiativeId: scopeInitiativeId } = useProgramScope() - const initiativeId = initiativeIdProp || scopeInitiativeId - - return ( - - {children} - - ) +/** + * AP2.3c — Scope-Gate ohne eigenen Data-Provider (Provider in ProgramScopeContext). + */ +export function ScopedInitiativeProvider({ children }) { + return children } diff --git a/frontend/src/layout/ScopedOperationsBridge.jsx b/frontend/src/layout/ScopedOperationsBridge.jsx new file mode 100644 index 0000000..299427b --- /dev/null +++ b/frontend/src/layout/ScopedOperationsBridge.jsx @@ -0,0 +1,12 @@ +import { InitiativeOperationsProvider } from '../context/InitiativeOperationsContext.jsx' +import { useProgramScope } from '../context/ProgramScopeContext.jsx' + +/** AP2.3c — ein Provider pro Vorhaben-Scope (unter ProgramScope). */ +export function ScopedOperationsBridge({ children }) { + const { initiativeId } = useProgramScope() + return ( + + {children} + + ) +} diff --git a/frontend/src/registry/operatingSliceLoaders.js b/frontend/src/registry/operatingSliceLoaders.js new file mode 100644 index 0000000..4f01aad --- /dev/null +++ b/frontend/src/registry/operatingSliceLoaders.js @@ -0,0 +1,107 @@ +/** + * AP2.3c — Data Slice Registry (lazy OM loads pro Operating Context). + */ +import { + getInitiative, + getInitiativeSteeringSnapshot, + listInitiativeActions, +} from '../api/initiatives.js' +import { listInitiativeBlockers } from '../api/blockers.js' +import { listInitiativeBacklog } from '../api/backlog.js' +import { listInitiativeRoadmapItems } from '../api/roadmap.js' +import { listInitiativeProjects } from '../api/projects.js' +import { listInitiativeEvidence } from '../api/evidence.js' +import { listInitiativeDecisions } from '../api/decisions.js' +import { listInitiativeReviews } from '../api/reviews.js' +import { listInitiativeRecurring } from '../api/recurring.js' +import { + listInitiativeWorkCycles, + getActiveWorkCycle, +} from '../api/workCycles.js' +import { listSteeringMethods } from '../api/steering.js' + +/** @type {Record Promise>} */ +export const SLICE_LOADERS = { + initiative: (initiativeId) => getInitiative(initiativeId), + actions: (initiativeId) => listInitiativeActions(initiativeId), + blockers: (initiativeId) => listInitiativeBlockers(initiativeId), + backlog: (initiativeId) => listInitiativeBacklog(initiativeId), + roadmap: (initiativeId) => listInitiativeRoadmapItems(initiativeId), + projects: (initiativeId) => listInitiativeProjects(initiativeId), + evidence: (initiativeId) => listInitiativeEvidence(initiativeId), + decisions: (initiativeId) => listInitiativeDecisions(initiativeId), + reviews: (initiativeId) => listInitiativeReviews(initiativeId), + recurring: (initiativeId) => listInitiativeRecurring(initiativeId), + work_cycles: async (initiativeId) => ({ + workCycles: await listInitiativeWorkCycles(initiativeId), + activeWorkCycle: await getActiveWorkCycle(initiativeId).catch(() => null), + }), + steering_methods: () => listSteeringMethods(), + steering_snapshot: (initiativeId) => getInitiativeSteeringSnapshot(initiativeId), +} + +const CORE_SLICES = ['actions'] + +/** + * @param {string} initiativeId + * @param {string[]} sliceKeys + * @returns {Promise>} + */ +export async function loadOperatingSlices(initiativeId, sliceKeys) { + const keys = new Set(['initiative', 'actions', ...(sliceKeys || [])]) + const entries = await Promise.all( + [...keys].map(async (key) => { + const loader = SLICE_LOADERS[key] + if (!loader) return [key, null] + try { + const value = await loader(initiativeId) + return [key, value] + } catch { + return [key, null] + } + }), + ) + return Object.fromEntries(entries) +} + +/** + * Wendet Slice-Ergebnisse auf InitiativeOperations-State-Setter an. + * @param {Record} slices + * @param {object} setters + */ +export function applySliceResults(slices, setters) { + if (slices.initiative != null) setters.setInitiative(slices.initiative) + if (Array.isArray(slices.actions)) setters.setActions(slices.actions) + if (Array.isArray(slices.blockers)) setters.setBlockers(slices.blockers) + if (Array.isArray(slices.backlog)) setters.setBacklogItems(slices.backlog) + if (Array.isArray(slices.roadmap)) setters.setRoadmapItems(slices.roadmap) + if (Array.isArray(slices.projects)) setters.setProjects(slices.projects) + if (Array.isArray(slices.evidence)) setters.setEvidenceItems(slices.evidence) + if (Array.isArray(slices.decisions)) setters.setDecisions(slices.decisions) + if (Array.isArray(slices.reviews)) setters.setReviews(slices.reviews) + if (Array.isArray(slices.recurring)) setters.setRecurringItems(slices.recurring) + if (Array.isArray(slices.steering_methods)) setters.setSteeringMethods(slices.steering_methods) + + if (slices.work_cycles && typeof slices.work_cycles === 'object') { + const wc = /** @type {{ workCycles?: unknown[], activeWorkCycle?: unknown }} */ ( + slices.work_cycles + ) + if (Array.isArray(wc.workCycles)) setters.setWorkCycles(wc.workCycles) + setters.setActiveWorkCycle(wc.activeWorkCycle || null) + } + + if (slices.steering_snapshot != null) { + setters.setSteeringSnapshot(slices.steering_snapshot) + } +} + +/** + * @param {string} sliceKey + * @param {string} initiativeId + * @returns {Promise} + */ +export async function reloadOperatingSlice(sliceKey, initiativeId) { + const loader = SLICE_LOADERS[sliceKey] + if (!loader) return null + return loader(initiativeId) +}