/** * 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 }