AP1.9b: Scope-Breadcrumb modus-sensitiv für Planen, Ausführen und Kontrolle.
Some checks failed
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) Successful in 42s
Test Suite / pytest-backend (push) Has been cancelled

Portfolio- und Vorhaben-Klicks bleiben im aktiven Modus; Objekt-Routen leiten auf passende Plan-/Work-Hubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-09 11:14:33 +02:00
parent afb07f70b9
commit aa7e1626ea
4 changed files with 158 additions and 8 deletions

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.16.3-ap1.9a" APP_VERSION = "0.16.4-ap1.9b"
DB_SCHEMA_VERSION = "014" DB_SCHEMA_VERSION = "014"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -1,8 +1,13 @@
import { Link } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { useProgramScope } from '../context/ProgramScopeContext.jsx' 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() { export function ProgramScopeBar() {
const location = useLocation()
const { const {
initiativeId, initiativeId,
projectId, projectId,
@ -12,12 +17,17 @@ export function ProgramScopeBar() {
setScope, setScope,
} = useProgramScope() } = useProgramScope()
const portfolioTo = resolvePortfolioHub(location.pathname)
const initiativeTo = initiativeId
? resolveInitiativeHub(location.pathname, initiativeId, scopedPath)
: null
return ( return (
<div className="program-scope-bar" aria-label="Programm-Scope"> <div className="program-scope-bar" aria-label="Programm-Scope">
<span className="program-scope-bar__label">Scope</span> <span className="program-scope-bar__label">Scope</span>
<nav className="program-scope-bar__trail"> <nav className="program-scope-bar__trail">
<Link <Link
to={scopedPath('/cockpit')} to={portfolioTo}
className={ className={
'program-scope-bar__crumb' + 'program-scope-bar__crumb' +
(!initiativeId ? ' program-scope-bar__crumb--active' : '') (!initiativeId ? ' program-scope-bar__crumb--active' : '')
@ -26,13 +36,13 @@ export function ProgramScopeBar() {
> >
Portfolio Portfolio
</Link> </Link>
{initiativeId && ( {initiativeId && initiativeTo && (
<> <>
<span className="program-scope-bar__sep" aria-hidden="true"> <span className="program-scope-bar__sep" aria-hidden="true">
</span> </span>
<Link <Link
to={scopedPath('/control/status', { initiativeId })} to={initiativeTo}
className={ className={
'program-scope-bar__crumb' + 'program-scope-bar__crumb' +
(!projectId ? ' program-scope-bar__crumb--active' : '') (!projectId ? ' program-scope-bar__crumb--active' : '')
@ -48,9 +58,12 @@ export function ProgramScopeBar() {
<span className="program-scope-bar__sep" aria-hidden="true"> <span className="program-scope-bar__sep" aria-hidden="true">
</span> </span>
<span className="program-scope-bar__crumb program-scope-bar__crumb--active"> <Link
to={projectPath(projectId)}
className="program-scope-bar__crumb program-scope-bar__crumb--active"
>
{projectTitle || 'Projekt'} {projectTitle || 'Projekt'}
</span> </Link>
</> </>
)} )}
</nav> </nav>

View File

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

View File

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