From 90934453e6ecc8b7f095a98e689efc9626f4a9c7 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 25 Jul 2026 10:26:37 +0200 Subject: [PATCH] =?UTF-8?q?AP2.3b:=20Frontend=20Profile=20Resolver=20?= =?UTF-8?q?=E2=80=94=20Operating=20Context=20statt=20methodUiDefaults.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/src/api/initiatives.js | 4 + frontend/src/components/MethodProcessBar.jsx | 2 +- frontend/src/config/methodUiDefaults.js | 4 +- frontend/src/hooks/useMethodUiContext.js | 44 ++--- .../src/pages/modes/PlanIndexRedirect.jsx | 14 +- .../src/registry/resolveOperatingProfile.js | 170 ++++++++++++++++++ .../registry/resolveOperatingProfile.test.js | 76 ++++++++ 7 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 frontend/src/registry/resolveOperatingProfile.js create mode 100644 frontend/src/registry/resolveOperatingProfile.test.js diff --git a/frontend/src/api/initiatives.js b/frontend/src/api/initiatives.js index dea76de..f1107d9 100644 --- a/frontend/src/api/initiatives.js +++ b/frontend/src/api/initiatives.js @@ -12,6 +12,10 @@ export function getInitiativeSteeringSnapshot(id) { return apiFetch(`/api/initiatives/${id}/steering-snapshot`) } +export function getInitiativeOperatingContext(id) { + return apiFetch(`/api/initiatives/${id}/operating-context`) +} + export function createInitiative(payload) { return apiFetch('/api/initiatives', { method: 'POST', diff --git a/frontend/src/components/MethodProcessBar.jsx b/frontend/src/components/MethodProcessBar.jsx index 5c4e8ef..09c618e 100644 --- a/frontend/src/components/MethodProcessBar.jsx +++ b/frontend/src/components/MethodProcessBar.jsx @@ -1,7 +1,7 @@ import { NavLink, useLocation } from 'react-router-dom' import { useProgramScope } from '../context/ProgramScopeContext.jsx' import { useMethodUiContext } from '../hooks/useMethodUiContext.js' -import { resolveActiveProcessStepKey } from '../config/methodUiDefaults.js' +import { resolveActiveProcessStepKey } from '../registry/resolveOperatingProfile.js' export function MethodProcessBar() { const location = useLocation() diff --git a/frontend/src/config/methodUiDefaults.js b/frontend/src/config/methodUiDefaults.js index f0db98a..88928ab 100644 --- a/frontend/src/config/methodUiDefaults.js +++ b/frontend/src/config/methodUiDefaults.js @@ -1,6 +1,6 @@ /** - * AP1.9d — Methoden-Default-UI: Archetyp/Methode steuert Prozessleiste, - * Modus-Defaults und sichtbare Nav-Knoten (Anti-Todo-Wand). + * AP1.9d — Methoden-Default-UI (DEPRECATED AP2.3b — Fallback bis Resolver live). + * Bevorzugt: GET /api/initiatives/:id/operating-context + resolveOperatingProfile.js * * @typedef {{ key: string, to: string, label: string, mode: 'plan'|'work'|'control' }} ProcessStep * @typedef {{ diff --git a/frontend/src/hooks/useMethodUiContext.js b/frontend/src/hooks/useMethodUiContext.js index 2b8fb7a..a251aab 100644 --- a/frontend/src/hooks/useMethodUiContext.js +++ b/frontend/src/hooks/useMethodUiContext.js @@ -1,24 +1,21 @@ import { useEffect, useMemo, useState } from 'react' -import { getInitiative } from '../api/initiatives.js' +import { getInitiativeOperatingContext } from '../api/initiatives.js' import { getActiveWorkCycle } from '../api/workCycles.js' -import { getInitiativeSteeringSnapshot } from '../api/initiatives.js' import { useProgramScope } from '../context/ProgramScopeContext.jsx' -import { resolveMethodUiDefaults } from '../config/methodUiDefaults.js' +import { resolveOperatingProfileFromInput } from '../registry/resolveOperatingProfile.js' /** - * Lädt Archetyp/Methode für methodengebundene UI (AP1.9d). + * Lädt Operating Context für methodengebundene UI (AP2.3b). */ export function useMethodUiContext() { const { initiativeId } = useProgramScope() - const [archetypeKey, setArchetypeKey] = useState(null) - const [methodKey, setMethodKey] = useState(null) + const [operatingContext, setOperatingContext] = useState(null) const [hasActiveSprint, setHasActiveSprint] = useState(false) const [loading, setLoading] = useState(false) useEffect(() => { if (!initiativeId) { - setArchetypeKey(null) - setMethodKey(null) + setOperatingContext(null) setHasActiveSprint(false) return undefined } @@ -27,20 +24,17 @@ export function useMethodUiContext() { setLoading(true) Promise.all([ - getInitiative(initiativeId), + getInitiativeOperatingContext(initiativeId).catch(() => null), getActiveWorkCycle(initiativeId).catch(() => null), - getInitiativeSteeringSnapshot(initiativeId).catch(() => null), ]) - .then(([initiative, activeCycle, snapshot]) => { + .then(([context, activeCycle]) => { if (cancelled) return - setArchetypeKey(initiative?.archetype_key || null) - setMethodKey(snapshot?.method_key || null) + setOperatingContext(context) setHasActiveSprint(Boolean(activeCycle?.id)) }) .catch(() => { if (!cancelled) { - setArchetypeKey(null) - setMethodKey(null) + setOperatingContext(null) setHasActiveSprint(false) } }) @@ -53,19 +47,25 @@ export function useMethodUiContext() { } }, [initiativeId]) - const input = useMemo( - () => ({ archetypeKey, methodKey, hasActiveSprint }), - [archetypeKey, methodKey, hasActiveSprint], + const profileInput = useMemo( + () => ({ operatingContext, hasActiveSprint }), + [operatingContext, hasActiveSprint], ) - const defaults = useMemo(() => resolveMethodUiDefaults(input), [input]) + const profile = useMemo( + () => resolveOperatingProfileFromInput(profileInput), + [profileInput], + ) return { initiativeId, - archetypeKey, - methodKey, + archetypeKey: operatingContext?.archetype_key || null, + methodKey: operatingContext?.method_key || null, + methodProfileKey: operatingContext?.method_profile_key || null, + operatingContext, hasActiveSprint, loading, - ...defaults, + dataSlices: profile.dataSlices, + ...profile, } } diff --git a/frontend/src/pages/modes/PlanIndexRedirect.jsx b/frontend/src/pages/modes/PlanIndexRedirect.jsx index 4e9e2f7..9223095 100644 --- a/frontend/src/pages/modes/PlanIndexRedirect.jsx +++ b/frontend/src/pages/modes/PlanIndexRedirect.jsx @@ -1,9 +1,9 @@ -import { Navigate } from 'react-router-dom' import { useEffect, useState } from 'react' +import { Navigate } from 'react-router-dom' import { useProgramScope } from '../../context/ProgramScopeContext.jsx' -import { getInitiative } from '../../api/initiatives.js' +import { getInitiativeOperatingContext } from '../../api/initiatives.js' import { getActiveWorkCycle } from '../../api/workCycles.js' -import { resolveMethodUiDefaults } from '../../config/methodUiDefaults.js' +import { resolveOperatingProfileFromInput } from '../../registry/resolveOperatingProfile.js' /** Portfolio ohne Scope → Übersicht; mit Vorhaben → methoden-Default (AP1.9d). */ export function PlanIndexRedirect() { @@ -17,13 +17,13 @@ export function PlanIndexRedirect() { } let cancelled = false Promise.all([ - getInitiative(initiativeId), + getInitiativeOperatingContext(initiativeId), getActiveWorkCycle(initiativeId).catch(() => null), ]) - .then(([initiative, activeCycle]) => { + .then(([context, activeCycle]) => { if (cancelled) return - const defaults = resolveMethodUiDefaults({ - archetypeKey: initiative?.archetype_key, + const defaults = resolveOperatingProfileFromInput({ + operatingContext: context, hasActiveSprint: Boolean(activeCycle?.id), }) setTarget(hrefWithScope(defaults.planDefaultRoute)) diff --git a/frontend/src/registry/resolveOperatingProfile.js b/frontend/src/registry/resolveOperatingProfile.js new file mode 100644 index 0000000..6269eb8 --- /dev/null +++ b/frontend/src/registry/resolveOperatingProfile.js @@ -0,0 +1,170 @@ +/** + * AP2.3b — Operating Profile Resolver (API-first, methodUiDefaults als Fallback). + */ +import { PLAN_OUTLINE_NODES } from '../plan/planOutlineNodes.js' +import { WORK_NAV_ITEMS } from '../config/workNav.js' +import { + resolveMethodUiDefaults, + resolveActiveProcessStepKey as resolveActiveProcessStepKeyFallback, +} from '../config/methodUiDefaults.js' + +/** + * @typedef {import('../config/methodUiDefaults.js').ProcessStep} ProcessStep + * @typedef {import('../config/methodUiDefaults.js').MethodUiDefaults} MethodUiDefaults + * @typedef {{ + * initiative_id?: string, + * archetype_key?: string | null, + * method_key?: string | null, + * method_profile_key?: string | null, + * ui_profile?: Partial | null, + * data_slices?: string[], + * method_capabilities?: object, + * }} OperatingContextResponse + * @typedef {{ + * archetypeKey?: string | null, + * methodKey?: string | null, + * hasActiveSprint?: boolean, + * operatingContext?: OperatingContextResponse | null, + * }} ResolveOperatingProfileInput + */ + +const EMPTY_PROFILE = { + processSteps: [], + planDefaultRoute: '/plan/profile', + workDefaultRoute: '/work/today', + controlDefaultRoute: '/control/status', + planOutlineKeys: null, + workNavKeys: null, +} + +/** + * @param {OperatingContextResponse | null | undefined} context + * @param {{ hasActiveSprint?: boolean }} [options] + * @returns {MethodUiDefaults & { dataSlices: string[] }} + */ +export function resolveOperatingProfile(context, options = {}) { + if (context?.ui_profile) { + const profile = context.ui_profile + let workDefaultRoute = profile.workDefaultRoute || EMPTY_PROFILE.workDefaultRoute + if ( + context.archetype_key === 'initiative.product' && + !options.hasActiveSprint + ) { + workDefaultRoute = '/work/today' + } + return { + processSteps: profile.processSteps || [], + planDefaultRoute: profile.planDefaultRoute || EMPTY_PROFILE.planDefaultRoute, + workDefaultRoute, + controlDefaultRoute: + profile.controlDefaultRoute || EMPTY_PROFILE.controlDefaultRoute, + planOutlineKeys: profile.planOutlineKeys ?? null, + workNavKeys: profile.workNavKeys ?? null, + dataSlices: context.data_slices || profile.dataSlices || [], + } + } + + const fallback = resolveMethodUiDefaults({ + archetypeKey: context?.archetype_key || null, + methodKey: context?.method_key || null, + hasActiveSprint: options.hasActiveSprint, + }) + return { + ...fallback, + dataSlices: context?.data_slices || [], + } +} + +/** + * @param {ResolveOperatingProfileInput} input + * @returns {MethodUiDefaults & { dataSlices: string[] }} + */ +export function resolveOperatingProfileFromInput(input = {}) { + if (input.operatingContext) { + return resolveOperatingProfile(input.operatingContext, { + hasActiveSprint: input.hasActiveSprint, + }) + } + const fallback = resolveMethodUiDefaults({ + archetypeKey: input.archetypeKey || null, + methodKey: input.methodKey || null, + hasActiveSprint: input.hasActiveSprint, + }) + return { ...fallback, dataSlices: [] } +} + +/** + * @param {ResolveOperatingProfileInput} input + * @returns {typeof PLAN_OUTLINE_NODES} + */ +export function resolvePlanOutlineNodesFromProfile(input = {}) { + const { planOutlineKeys } = resolveOperatingProfileFromInput(input) + if (!planOutlineKeys) return PLAN_OUTLINE_NODES + const allowed = new Set(planOutlineKeys) + return PLAN_OUTLINE_NODES.filter((node) => allowed.has(node.key)) +} + +/** + * @param {ResolveOperatingProfileInput} input + * @returns {typeof WORK_NAV_ITEMS} + */ +export function resolveWorkNavItemsFromProfile(input = {}) { + const { workNavKeys } = resolveOperatingProfileFromInput(input) + if (!workNavKeys) return WORK_NAV_ITEMS + const order = new Map(workNavKeys.map((key, index) => [key, index])) + return WORK_NAV_ITEMS.filter((item) => order.has(item.key)).sort( + (a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0), + ) +} + +/** + * @param {string} pathname + * @param {ProcessStep[]} steps + */ +export function resolveActiveProcessStepKey(pathname, steps) { + return resolveActiveProcessStepKeyFallback(pathname, steps) +} + +/** + * @param {string} routePath + * @param {ResolveOperatingProfileInput} input + * @returns {boolean} + */ +export function isRouteAllowedForProfile(routePath, input = {}) { + const profile = resolveOperatingProfileFromInput(input) + const normalized = (routePath || '').replace(/\/$/, '') + + if (normalized.startsWith('/plan/sprint') || normalized.startsWith('/work/sprint')) { + return profile.dataSlices.includes('work_cycles') + } + if (normalized.startsWith('/plan/structure')) { + return profile.dataSlices.includes('projects') + } + if (normalized.startsWith('/plan/inbox')) { + return profile.dataSlices.includes('backlog') + } + if (normalized.startsWith('/control/journey')) { + const steps = profile.processSteps || [] + return steps.some((s) => s.key === 'journey') + } + + return true +} + +/** + * @param {string} routePath + * @param {ResolveOperatingProfileInput} input + * @returns {string} + */ +export function resolveRedirectForDisallowedRoute(routePath, input = {}) { + const profile = resolveOperatingProfileFromInput(input) + const normalized = (routePath || '').replace(/\/$/, '') + + if (normalized.startsWith('/work/')) { + return profile.workDefaultRoute + } + if (normalized.startsWith('/control/')) { + return profile.controlDefaultRoute + } + return profile.planDefaultRoute +} diff --git a/frontend/src/registry/resolveOperatingProfile.test.js b/frontend/src/registry/resolveOperatingProfile.test.js new file mode 100644 index 0000000..902ff44 --- /dev/null +++ b/frontend/src/registry/resolveOperatingProfile.test.js @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { + isRouteAllowedForProfile, + resolveOperatingProfile, + resolveOperatingProfileFromInput, + resolveRedirectForDisallowedRoute, +} from './resolveOperatingProfile.js' + +const PRODUCT_CONTEXT = { + initiative_id: 'test-id', + archetype_key: 'initiative.product', + method_key: 'continuous_product', + ui_profile: { + processSteps: [ + { key: 'inbox', to: '/plan/inbox', label: 'Eingang', mode: 'plan' }, + { key: 'sprint-plan', to: '/plan/sprint', label: 'Sprint planen', mode: 'plan' }, + ], + planDefaultRoute: '/plan/inbox', + workDefaultRoute: '/work/sprint', + controlDefaultRoute: '/control/status', + planOutlineKeys: ['profile', 'inbox', 'sprint', 'gates'], + workNavKeys: ['sprint', 'today', 'mine'], + }, + data_slices: ['backlog', 'actions', 'roadmap', 'work_cycles', 'projects'], +} + +const SUPPORT_QUEUE_CONTEXT = { + archetype_key: 'initiative.support_queue', + method_key: 'queue_pull', + ui_profile: { + processSteps: [], + planDefaultRoute: '/plan/inbox', + workDefaultRoute: '/work/today', + controlDefaultRoute: '/control/status', + planOutlineKeys: ['profile', 'inbox', 'work'], + workNavKeys: ['today', 'mine'], + }, + data_slices: ['backlog', 'actions', 'blockers'], +} + +describe('resolveOperatingProfile', () => { + it('liefert UI-Profil aus Operating Context', () => { + const profile = resolveOperatingProfile(PRODUCT_CONTEXT) + expect(profile.planDefaultRoute).toBe('/plan/inbox') + expect(profile.processSteps).toHaveLength(2) + expect(profile.dataSlices).toContain('work_cycles') + }) + + it('Product ohne aktiven Sprint → work/today', () => { + const profile = resolveOperatingProfile(PRODUCT_CONTEXT, { hasActiveSprint: false }) + expect(profile.workDefaultRoute).toBe('/work/today') + }) + + it('Product mit aktivem Sprint → work/sprint', () => { + const profile = resolveOperatingProfile(PRODUCT_CONTEXT, { hasActiveSprint: true }) + expect(profile.workDefaultRoute).toBe('/work/sprint') + }) + + it('Fallback ohne Operating Context', () => { + const profile = resolveOperatingProfileFromInput({ + archetypeKey: 'initiative.linear_project', + }) + expect(profile.planDefaultRoute).toBe('/plan/gates') + }) + + it('support_queue erlaubt kein /plan/sprint', () => { + const input = { operatingContext: SUPPORT_QUEUE_CONTEXT } + expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(false) + expect(resolveRedirectForDisallowedRoute('/plan/sprint', input)).toBe('/plan/inbox') + }) + + it('product erlaubt /plan/sprint', () => { + const input = { operatingContext: PRODUCT_CONTEXT } + expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(true) + }) +})