AP2.3b: Frontend Profile Resolver — Operating Context statt methodUiDefaults.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-25 10:26:37 +02:00
parent 4ad908bfbf
commit 90934453e6
7 changed files with 282 additions and 32 deletions

View File

@ -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',

View File

@ -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()

View File

@ -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 {{

View File

@ -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,
}
}

View File

@ -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))

View File

@ -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<MethodUiDefaults> | 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
}

View File

@ -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)
})
})