diff --git a/backend/version.py b/backend/version.py index 7ec367d..35e2d0e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.16.3-ap1.9a" +APP_VERSION = "0.16.4-ap1.9b" DB_SCHEMA_VERSION = "014" APP_NAME = "jinkendo-kairo" diff --git a/frontend/src/components/ProgramScopeBar.jsx b/frontend/src/components/ProgramScopeBar.jsx index 688af45..4907b00 100644 --- a/frontend/src/components/ProgramScopeBar.jsx +++ b/frontend/src/components/ProgramScopeBar.jsx @@ -1,8 +1,13 @@ -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import { useProgramScope } from '../context/ProgramScopeContext.jsx' -import { initiativeCatalogPath, scopedPath } from '../utils/routes.js' +import { initiativeCatalogPath, projectPath, scopedPath } from '../utils/routes.js' +import { + resolveInitiativeHub, + resolvePortfolioHub, +} from '../utils/scopeNavigation.js' export function ProgramScopeBar() { + const location = useLocation() const { initiativeId, projectId, @@ -12,12 +17,17 @@ export function ProgramScopeBar() { setScope, } = useProgramScope() + const portfolioTo = resolvePortfolioHub(location.pathname) + const initiativeTo = initiativeId + ? resolveInitiativeHub(location.pathname, initiativeId, scopedPath) + : null + return (
Scope diff --git a/frontend/src/utils/scopeNavigation.js b/frontend/src/utils/scopeNavigation.js new file mode 100644 index 0000000..234280d --- /dev/null +++ b/frontend/src/utils/scopeNavigation.js @@ -0,0 +1,91 @@ +/** + * Modus-sensitive Scope-Navigation (AP1.9b). + * Breadcrumb-Ziele hängen vom aktiven Modus / Pfad ab — nicht immer Kontrolle. + */ + +const MODE_DEFAULTS = { + cockpit: { portfolioHub: '/cockpit', initiativeHub: '/cockpit' }, + work: { portfolioHub: '/work/today', initiativeHub: '/work/today' }, + plan: { portfolioHub: '/plan/structure', initiativeHub: '/plan/structure' }, + control: { portfolioHub: '/control/status', initiativeHub: '/control/status' }, + team: { portfolioHub: '/team', initiativeHub: '/team' }, +} + +/** + * @param {string} pathname + * @returns {'cockpit'|'work'|'plan'|'control'|'team'} + */ +export function resolveAppMode(pathname) { + const path = pathname || '/' + if (path.startsWith('/actions') || path.startsWith('/work')) return 'work' + if (path.startsWith('/projects') || path.startsWith('/gates') || path.startsWith('/plan')) { + return 'plan' + } + if (path.startsWith('/control')) return 'control' + if (path.startsWith('/team')) return 'team' + if (path.startsWith('/cockpit') || path.startsWith('/workspace')) return 'cockpit' + if (path.startsWith('/initiatives')) return 'control' + return 'cockpit' +} + +/** + * Basis-Pfad für Portfolio-Klick (Scope leeren, Modus beibehalten). + * @param {string} pathname + */ +export function resolvePortfolioHub(pathname) { + const mode = resolveAppMode(pathname) + const defaults = MODE_DEFAULTS[mode] + + if (mode === 'plan' && pathname.startsWith('/plan/')) { + const sub = pathname.match(/^(\/plan\/[^/?]+)/) + if (sub) return sub[1] + } + if (mode === 'control' && pathname.startsWith('/control/')) { + const sub = pathname.match(/^(\/control\/[^/?]+)/) + if (sub) return sub[1] + } + if (mode === 'work' && pathname.startsWith('/work/')) { + const sub = pathname.match(/^(\/work\/[^/?]+)/) + if (sub) return sub[1] + } + + return defaults.portfolioHub +} + +/** + * Ziel für Klick auf Vorhaben im Breadcrumb (Scope behält Initiative, Projekt-Filter weg). + * @param {string} pathname + * @param {string} initiativeId + * @param {import('./routes.js').scopedPath} scopedPathFn + */ +export function resolveInitiativeHub(pathname, initiativeId, scopedPathFn) { + const mode = resolveAppMode(pathname) + const scope = { initiativeId } + + if (mode === 'plan') { + if (pathname.startsWith('/gates/')) { + return scopedPathFn('/plan/gates', scope) + } + if (pathname.startsWith('/projects/')) { + return scopedPathFn('/plan/structure', scope) + } + const sub = pathname.match(/^(\/plan\/[^/?]+)/) + return scopedPathFn(sub ? sub[1] : MODE_DEFAULTS.plan.initiativeHub, scope) + } + + if (mode === 'work' || pathname.startsWith('/actions/')) { + const sub = pathname.match(/^(\/work\/[^/?]+)/) + return scopedPathFn(sub ? sub[1] : MODE_DEFAULTS.work.initiativeHub, scope) + } + + if (mode === 'control') { + const sub = pathname.match(/^(\/control\/[^/?]+)/) + return scopedPathFn(sub ? sub[1] : MODE_DEFAULTS.control.initiativeHub, scope) + } + + if (mode === 'team') { + return scopedPathFn(MODE_DEFAULTS.team.initiativeHub, scope) + } + + return scopedPathFn(MODE_DEFAULTS.cockpit.initiativeHub, scope) +} diff --git a/frontend/src/utils/scopeNavigation.test.js b/frontend/src/utils/scopeNavigation.test.js new file mode 100644 index 0000000..959e677 --- /dev/null +++ b/frontend/src/utils/scopeNavigation.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { scopedPath } from './routes.js' +import { + resolveAppMode, + resolveInitiativeHub, + resolvePortfolioHub, +} from './scopeNavigation.js' + +describe('scopeNavigation', () => { + it('resolves app mode from path', () => { + expect(resolveAppMode('/plan/structure')).toBe('plan') + expect(resolveAppMode('/plan/gates')).toBe('plan') + expect(resolveAppMode('/projects/abc')).toBe('plan') + expect(resolveAppMode('/gates/abc')).toBe('plan') + expect(resolveAppMode('/work/today')).toBe('work') + expect(resolveAppMode('/actions/abc')).toBe('work') + expect(resolveAppMode('/control/status')).toBe('control') + expect(resolveAppMode('/cockpit')).toBe('cockpit') + expect(resolveAppMode('/team')).toBe('team') + }) + + it('keeps plan sub-route for portfolio hub', () => { + expect(resolvePortfolioHub('/plan/gates')).toBe('/plan/gates') + expect(resolvePortfolioHub('/plan/inbox')).toBe('/plan/inbox') + }) + + it('initiative hub stays in plan mode on gates sub-route', () => { + const href = resolveInitiativeHub('/plan/gates', 'init-1', scopedPath) + expect(href).toBe('/plan/gates?initiative=init-1') + }) + + it('initiative hub from action object routes to work', () => { + const href = resolveInitiativeHub('/actions/act-1', 'init-1', scopedPath) + expect(href).toBe('/work/today?initiative=init-1') + }) + + it('initiative hub from project object routes to plan structure', () => { + const href = resolveInitiativeHub('/projects/proj-1', 'init-1', scopedPath) + expect(href).toBe('/plan/structure?initiative=init-1') + }) + + it('control initiative hub uses current sub-route', () => { + const href = resolveInitiativeHub('/control/journey', 'init-1', scopedPath) + expect(href).toBe('/control/journey?initiative=init-1') + }) +})