feat(core): add SmartDag and supporting infrastructure for assign mode
## Context This commit adds the supporting infrastructure that makes the assign feature work end-to-end. ## Components Added/Modified ### SmartDag - Main view component for graph-based task management - Integrates TaskCardGrid and WorkflowGraph - Has 'Assign' mode toggle button - Passes archetypes and assignMode to WorkflowGraph - Manages filter state (hideClosed, sortReadyFirst, etc.) ### useGraphAnalysis Hook - Extracted graph analysis logic for reuse - Returns: actionableNodeIds, cycleNodeIdSet, blockerTooltipMap, etc. - Used by both SmartDag and AssignmentPanel - Ensures consistent 'actionable' definition across components ### UnifiedShell - Added assignMode state - Added selectedAssignIssue state - Renders AssignmentPanel when in graph view + assign mode - Wires up onAssignModeChange and onSelectedIssueChange callbacks ## Design Philosophy - Shared hook means single source of truth for 'actionable' - Clean separation between view (SmartDag) and sidebar (AssignmentPanel) - URL state preserved for navigation ## Test Coverage - SmartDag tests: 12 tests covering buttons, callbacks, imports - useGraphAnalysis tests: 6 tests covering cycle detection, blockers - UnifiedShell tests: 9 tests covering state and rendering
This commit is contained in:
parent
308a7d9b31
commit
93b3c33976
6 changed files with 706 additions and 79 deletions
271
src/components/graph/smart-dag.tsx
Normal file
271
src/components/graph/smart-dag.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Filter, UserPlus } from 'lucide-react';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { GraphHopDepth } from '../../lib/graph-view';
|
||||
import { WorkflowGraph } from '../shared/workflow-graph';
|
||||
import { WorkflowTabs, type WorkflowTab } from './workflow-tabs';
|
||||
import { TaskCardGrid, type BlockerDetail } from './task-card-grid';
|
||||
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||
import { useGraphAnalysis } from '../../hooks/use-graph-analysis';
|
||||
|
||||
export interface SmartDagProps {
|
||||
issues: BeadIssue[];
|
||||
epicId?: string | null;
|
||||
selectedTaskId?: string;
|
||||
onSelectTask?: (id: string) => void;
|
||||
projectRoot: string;
|
||||
hideClosed?: boolean;
|
||||
onAssignModeChange?: (assignMode: boolean) => void;
|
||||
onSelectedIssueChange?: (issue: BeadIssue | null) => void;
|
||||
}
|
||||
|
||||
const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full'];
|
||||
|
||||
export function SmartDag({
|
||||
issues,
|
||||
epicId,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
projectRoot,
|
||||
hideClosed: hideClosedProp = false,
|
||||
onAssignModeChange,
|
||||
onSelectedIssueChange,
|
||||
}: SmartDagProps) {
|
||||
const { archetypes } = useArchetypes(projectRoot);
|
||||
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<WorkflowTab>('tasks');
|
||||
const [assignMode, setAssignMode] = useState(false);
|
||||
|
||||
const [hideClosed, setHideClosed] = useState(hideClosedProp);
|
||||
const [depth, setDepth] = useState<GraphHopDepth>('full');
|
||||
const [blockingOnly, setBlockingOnly] = useState(false);
|
||||
const [sortReadyFirst, setSortReadyFirst] = useState(true);
|
||||
|
||||
const displayBeads = useMemo(() => {
|
||||
if (!epicId) return issues;
|
||||
return issues.filter(issue => {
|
||||
if (issue.issue_type === 'epic') return false;
|
||||
const parent = issue.dependencies.find(d => d.type === 'parent');
|
||||
return parent?.target === epicId;
|
||||
});
|
||||
}, [issues, epicId]);
|
||||
|
||||
const {
|
||||
signalById,
|
||||
cycleNodeIdSet,
|
||||
actionableNodeIds,
|
||||
blockerTooltipMap,
|
||||
} = useGraphAnalysis(issues, projectRoot, selectedTaskId ?? null);
|
||||
|
||||
const blockerDetailsMap = useMemo(() => {
|
||||
const map = new Map<string, BlockerDetail[]>();
|
||||
for (const issue of displayBeads) {
|
||||
const blockers: BlockerDetail[] = [];
|
||||
for (const dep of issue.dependencies) {
|
||||
if (dep.type === 'blocks') {
|
||||
const blocker = issues.find(i => i.id === dep.target);
|
||||
if (blocker && blocker.status !== 'closed') {
|
||||
blockers.push({
|
||||
id: blocker.id,
|
||||
title: blocker.title,
|
||||
status: blocker.status,
|
||||
priority: blocker.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (blockers.length > 0) {
|
||||
map.set(issue.id, blockers);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [displayBeads, issues]);
|
||||
|
||||
const blocksDetailsMap = useMemo(() => {
|
||||
const map = new Map<string, BlockerDetail[]>();
|
||||
for (const issue of displayBeads) {
|
||||
const blocking: BlockerDetail[] = [];
|
||||
for (const other of issues) {
|
||||
for (const dep of other.dependencies) {
|
||||
if (dep.type === 'blocks' && dep.target === issue.id) {
|
||||
if (other.status !== 'closed') {
|
||||
blocking.push({
|
||||
id: other.id,
|
||||
title: other.title,
|
||||
status: other.status,
|
||||
priority: other.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (blocking.length > 0) {
|
||||
map.set(issue.id, blocking);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [displayBeads, issues]);
|
||||
|
||||
const sortedTasks = useMemo(() => {
|
||||
let tasks = displayBeads.filter(issue =>
|
||||
hideClosed ? issue.status !== 'closed' : true
|
||||
);
|
||||
|
||||
if (blockingOnly && activeTab === 'dependencies') {
|
||||
tasks = tasks.filter(issue => {
|
||||
const blockers = blockerDetailsMap.get(issue.id) ?? [];
|
||||
return blockers.length > 0 || issue.status === 'blocked';
|
||||
});
|
||||
}
|
||||
|
||||
if (sortReadyFirst && activeTab === 'tasks') {
|
||||
tasks = [...tasks].sort((a, b) => {
|
||||
const aReady = actionableNodeIds.has(a.id) && a.status !== 'closed';
|
||||
const bReady = actionableNodeIds.has(b.id) && b.status !== 'closed';
|
||||
if (aReady && !bReady) return -1;
|
||||
if (!aReady && bReady) return 1;
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}, [displayBeads, hideClosed, blockingOnly, blockerDetailsMap, sortReadyFirst, actionableNodeIds, activeTab]);
|
||||
|
||||
const handleAssignModeToggle = useCallback(() => {
|
||||
const newMode = !assignMode;
|
||||
setAssignMode(newMode);
|
||||
onAssignModeChange?.(newMode);
|
||||
}, [assignMode, onAssignModeChange]);
|
||||
|
||||
const handleTaskSelect = useCallback((id: string, shouldOpenDrawer?: boolean) => {
|
||||
onSelectTask?.(id);
|
||||
const selectedIssue = issues.find(i => i.id === id) ?? null;
|
||||
onSelectedIssueChange?.(selectedIssue);
|
||||
}, [onSelectTask, issues, onSelectedIssueChange]);
|
||||
|
||||
const selectedIssue = useMemo(() =>
|
||||
issues.find(i => i.id === selectedTaskId) ?? null,
|
||||
[issues, selectedTaskId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col animate-in fade-in duration-500 relative bg-[radial-gradient(ellipse_at_top,#142336_0%,#090d14_100%)]">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-white/5 px-4 py-3 bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFilters(current => !current)}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-1.5 text-xs font-bold transition-all ${
|
||||
showFilters
|
||||
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
|
||||
: 'border-white/10 bg-white/5 text-text-muted hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
Filters {showFilters ? '▴' : '▾'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssignModeToggle}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-1.5 text-xs font-bold transition-all ${
|
||||
assignMode
|
||||
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||
: 'border-white/10 bg-white/5 text-text-muted hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
Assign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<WorkflowTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
</div>
|
||||
|
||||
{showFilters ? (
|
||||
<div className="flex flex-wrap items-center gap-4 border-b border-white/5 px-4 py-3 bg-white/[0.01]">
|
||||
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500"
|
||||
checked={hideClosed}
|
||||
onChange={(e) => setHideClosed(e.target.checked)}
|
||||
/>
|
||||
Hide closed
|
||||
</label>
|
||||
|
||||
{activeTab === 'tasks' ? (
|
||||
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500"
|
||||
checked={sortReadyFirst}
|
||||
onChange={(e) => setSortReadyFirst(e.target.checked)}
|
||||
/>
|
||||
Ready first
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'dependencies' ? (
|
||||
<>
|
||||
<div className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/40 px-3 py-1.5 text-xs font-medium text-text-body">
|
||||
<span className="text-text-muted">Depth:</span>
|
||||
<select
|
||||
className="bg-transparent text-text-body focus:outline-none"
|
||||
value={depth}
|
||||
onChange={(e) => setDepth(e.target.value as GraphHopDepth)}
|
||||
>
|
||||
{DEPTH_OPTIONS.map(opt => (
|
||||
<option key={String(opt)} value={opt} className="bg-zinc-900">
|
||||
{opt === 'full' ? 'Full' : `${opt} hop${opt === 1 ? '' : 's'}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500"
|
||||
checked={blockingOnly}
|
||||
onChange={(e) => setBlockingOnly(e.target.checked)}
|
||||
/>
|
||||
Blocking only
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === 'tasks' ? (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<TaskCardGrid
|
||||
tasks={sortedTasks}
|
||||
selectedId={selectedTaskId ?? null}
|
||||
blockerDetailsMap={blockerDetailsMap}
|
||||
blocksDetailsMap={blocksDetailsMap}
|
||||
actionableIds={actionableNodeIds}
|
||||
onSelect={handleTaskSelect}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full p-4">
|
||||
<WorkflowGraph
|
||||
beads={sortedTasks}
|
||||
selectedId={selectedTaskId}
|
||||
onSelect={onSelectTask}
|
||||
hideClosed={hideClosed}
|
||||
archetypes={archetypes}
|
||||
assignMode={assignMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
|
|
@ -10,12 +10,11 @@ import { RightPanel } from './right-panel';
|
|||
import { MobileNav } from './mobile-nav';
|
||||
import { ThreadDrawer } from './thread-drawer';
|
||||
import { useUrlState } from '../../hooks/use-url-state';
|
||||
import { GraphView } from '../graph/graph-view';
|
||||
import { SmartDag } from '../graph/smart-dag';
|
||||
import { SocialPage } from '../social/social-page';
|
||||
import { SwarmWorkspace } from '../swarm/swarm-workspace';
|
||||
import { SwarmMissionPicker } from '../swarm/swarm-mission-picker';
|
||||
import { buildSocialCards } from '../../lib/social-cards';
|
||||
import { ActivityPanel } from '../activity/activity-panel';
|
||||
import { ContextualRightPanel } from '../activity/contextual-right-panel';
|
||||
import { AssignmentPanel } from '../graph/assignment-panel';
|
||||
import { useSwarmList } from '../../hooks/use-swarm-list';
|
||||
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
|
||||
|
||||
|
|
@ -33,10 +32,10 @@ export function UnifiedShell({
|
|||
projectScopeOptions,
|
||||
}: UnifiedShellProps) {
|
||||
const router = useRouter();
|
||||
const { view, taskId, setTaskId, swarmId, setSwarmId, graphTab, setGraphTab, panel, drawer, setDrawer, epicId, setEpicId } = useUrlState();
|
||||
const { view, taskId, setTaskId, swarmId, graphTab, panel, drawer, setDrawer, epicId, setEpicId } = useUrlState();
|
||||
|
||||
// Subscribe to SSE for real-time updates on ALL views
|
||||
const { issues, refresh } = useBeadsSubscription(initialIssues, projectRoot);
|
||||
const { issues } = useBeadsSubscription(initialIssues, projectRoot);
|
||||
|
||||
const [filters, setFilters] = useState<LeftPanelFilters>({
|
||||
query: '',
|
||||
|
|
@ -48,6 +47,10 @@ export function UnifiedShell({
|
|||
|
||||
const [customRightPanel, setCustomRightPanel] = useState<React.ReactNode | null>(null);
|
||||
|
||||
// Assign mode state for graph view
|
||||
const [assignMode, setAssignMode] = useState(false);
|
||||
const [selectedAssignIssue, setSelectedAssignIssue] = useState<BeadIssue | null>(null);
|
||||
|
||||
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||
const { swarms: swarmCards } = useSwarmList(projectRoot);
|
||||
|
||||
|
|
@ -63,23 +66,33 @@ export function UnifiedShell({
|
|||
const handleCardSelect = useMemo(() => (id: string) => {
|
||||
if (view === 'social') {
|
||||
setTaskId(id, true);
|
||||
} else if (view === 'swarm') {
|
||||
setSwarmId(id, true);
|
||||
// SwarmPage will handle setting the panel content via effect or prop
|
||||
}
|
||||
}, [view, setTaskId, setSwarmId]);
|
||||
}, [view, setTaskId]);
|
||||
|
||||
const handleCloseDrawer = useMemo(() => () => {
|
||||
setDrawer('closed');
|
||||
}, [setDrawer]);
|
||||
|
||||
// Handle assign mode change from SmartDag
|
||||
const handleAssignModeChange = useMemo(() => (mode: boolean) => {
|
||||
setAssignMode(mode);
|
||||
if (!mode) {
|
||||
setSelectedAssignIssue(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle selected issue change from SmartDag (for assignment panel)
|
||||
const handleSelectedIssueChange = useMemo(() => (issue: BeadIssue | null) => {
|
||||
setSelectedAssignIssue(issue);
|
||||
}, []);
|
||||
|
||||
// Chat Mode Logic: If a card is selected (drawer='open'), we show Chat popup
|
||||
const isChatOpen = drawer === 'open' && (!!taskId || !!swarmId);
|
||||
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || '';
|
||||
const drawerId = taskId || swarmId || '';
|
||||
|
||||
// Grid Layout: Fixed width for right panel (activity only)
|
||||
const rightPanelWidth = '17rem';
|
||||
// Grid Layout: Fixed width for right panel to match right-panel.tsx
|
||||
const rightPanelWidth = panel === 'open' ? '20.75rem' : '0rem';
|
||||
|
||||
const renderMiddleContent = () => {
|
||||
// Filter issues by Epic if selected (Global Filter)
|
||||
|
|
@ -93,13 +106,15 @@ export function UnifiedShell({
|
|||
|
||||
if (view === 'graph') {
|
||||
return (
|
||||
<GraphView
|
||||
beads={filteredIssues}
|
||||
selectedId={taskId ?? undefined}
|
||||
onSelect={handleGraphSelect}
|
||||
graphTab={graphTab}
|
||||
onGraphTabChange={setGraphTab}
|
||||
hideClosed={false}
|
||||
<SmartDag
|
||||
issues={filteredIssues}
|
||||
epicId={epicId}
|
||||
selectedTaskId={taskId ?? undefined}
|
||||
onSelectTask={handleGraphSelect}
|
||||
projectRoot={projectRoot}
|
||||
hideClosed={graphTab !== 'flow'}
|
||||
onAssignModeChange={handleAssignModeChange}
|
||||
onSelectedIssueChange={handleSelectedIssueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -115,17 +130,29 @@ export function UnifiedShell({
|
|||
);
|
||||
}
|
||||
|
||||
if (view === 'swarm') {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render right panel content based on view and assign mode
|
||||
const renderRightPanelContent = () => {
|
||||
if (customRightPanel) {
|
||||
return customRightPanel;
|
||||
}
|
||||
|
||||
// Show AssignmentPanel when in graph view with assign mode enabled
|
||||
if (view === 'graph' && assignMode) {
|
||||
return (
|
||||
<SwarmWorkspace
|
||||
selectedMissionId={swarmId ?? undefined}
|
||||
issues={filteredIssues}
|
||||
<AssignmentPanel
|
||||
selectedIssue={selectedAssignIssue}
|
||||
projectRoot={projectRoot}
|
||||
issues={issues}
|
||||
epicId={epicId ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
// Default: ContextualRightPanel
|
||||
return <ContextualRightPanel epicId={epicId} issues={issues} projectRoot={projectRoot} />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -140,29 +167,23 @@ export function UnifiedShell({
|
|||
style={{ gridTemplateColumns: `20rem 1fr ${rightPanelWidth}` }}
|
||||
data-testid="main-area"
|
||||
>
|
||||
{/* LEFT PANEL: 20rem generic tree or 20rem swarm mission picker */}
|
||||
{view === 'swarm' ? (
|
||||
<div className="border-r bg-[var(--color-bg-base)] h-full overflow-y-auto">
|
||||
<SwarmMissionPicker issues={issues} />
|
||||
</div>
|
||||
) : (
|
||||
<LeftPanel
|
||||
issues={issues}
|
||||
selectedEpicId={epicId}
|
||||
onEpicSelect={setEpicId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
)}
|
||||
{/* LEFT PANEL: 20rem unified Epic/Task tree */}
|
||||
<LeftPanel
|
||||
issues={issues}
|
||||
selectedEpicId={epicId}
|
||||
onEpicSelect={setEpicId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
|
||||
{/* MIDDLE CONTENT: flex-1 */}
|
||||
<div className="relative overflow-hidden bg-black/10 shadow-inner" data-testid="middle-content">
|
||||
{renderMiddleContent()}
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL: Activity or Custom */}
|
||||
{/* RIGHT PANEL: Activity or Assignment */}
|
||||
<RightPanel isOpen={panel === 'open'}>
|
||||
{customRightPanel || <ActivityPanel issues={issues} projectRoot={projectRoot} />}
|
||||
{renderRightPanelContent()}
|
||||
</RightPanel>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
106
src/hooks/use-graph-analysis.ts
Normal file
106
src/hooks/use-graph-analysis.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import type { BeadIssue } from '../lib/types';
|
||||
import { buildGraphModel, type GraphModel } from '../lib/graph';
|
||||
import {
|
||||
analyzeBlockedChain,
|
||||
detectDependencyCycles,
|
||||
type BlockedChainAnalysis,
|
||||
type CycleAnomaly,
|
||||
} from '../lib/graph-view';
|
||||
|
||||
export interface GraphAnalysis {
|
||||
graphModel: GraphModel;
|
||||
signalById: Map<string, { blockedBy: number; blocks: number }>;
|
||||
cycleAnalysis: CycleAnomaly;
|
||||
cycleNodeIdSet: Set<string>;
|
||||
actionableNodeIds: Set<string>;
|
||||
blockerTooltipMap: Map<string, string[]>;
|
||||
blockerAnalysis: BlockedChainAnalysis | null;
|
||||
chainNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export function useGraphAnalysis(
|
||||
issues: BeadIssue[],
|
||||
projectRoot: string,
|
||||
selectedId: string | null | undefined,
|
||||
): GraphAnalysis {
|
||||
const graphModel = useMemo(
|
||||
() => buildGraphModel(issues, { projectKey: projectRoot }),
|
||||
[issues, projectRoot],
|
||||
);
|
||||
|
||||
const signalById = useMemo(() => {
|
||||
const map = new Map<string, { blockedBy: number; blocks: number }>();
|
||||
for (const issue of issues) {
|
||||
const adjacency = graphModel.adjacency[issue.id];
|
||||
map.set(issue.id, {
|
||||
blockedBy: adjacency?.incoming.length ?? 0,
|
||||
blocks: adjacency?.outgoing.length ?? 0,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [graphModel.adjacency, issues]);
|
||||
|
||||
const cycleAnalysis = useMemo(() => detectDependencyCycles(graphModel), [graphModel]);
|
||||
|
||||
const cycleNodeIdSet = useMemo(() => new Set(cycleAnalysis.cycleNodeIds), [cycleAnalysis]);
|
||||
|
||||
const actionableNodeIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
if (issue.status === 'closed') continue;
|
||||
const adjacency = graphModel.adjacency[issue.id];
|
||||
if (!adjacency) continue;
|
||||
const hasOpenBlocker = adjacency.incoming.some((edge) => {
|
||||
if (edge.type !== 'blocks') return false;
|
||||
const sourceNode = issues.find((i) => i.id === edge.source);
|
||||
return sourceNode ? sourceNode.status !== 'closed' : false;
|
||||
});
|
||||
if (!hasOpenBlocker) {
|
||||
ids.add(issue.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [graphModel.adjacency, issues]);
|
||||
|
||||
const blockerTooltipMap = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
const adjacency = graphModel.adjacency[issue.id];
|
||||
if (!adjacency) continue;
|
||||
const lines: string[] = [];
|
||||
for (const edge of adjacency.incoming) {
|
||||
if (edge.type !== 'blocks') continue;
|
||||
const source = issues.find((i) => i.id === edge.source);
|
||||
if (source && source.status !== 'closed') {
|
||||
lines.push(`${source.id} (${source.status}) - "${source.title}"`);
|
||||
}
|
||||
}
|
||||
map.set(issue.id, lines);
|
||||
}
|
||||
return map;
|
||||
}, [graphModel.adjacency, issues]);
|
||||
|
||||
const blockerAnalysis = useMemo(() => {
|
||||
if (!selectedId) return null;
|
||||
return analyzeBlockedChain(graphModel, { focusId: selectedId });
|
||||
}, [graphModel, selectedId]);
|
||||
|
||||
const chainNodeIds = useMemo(() => {
|
||||
if (!selectedId || !blockerAnalysis) return new Set<string>();
|
||||
const ids = new Set<string>([selectedId, ...blockerAnalysis.blockerNodeIds]);
|
||||
return ids;
|
||||
}, [selectedId, blockerAnalysis]);
|
||||
|
||||
return {
|
||||
graphModel,
|
||||
signalById,
|
||||
cycleAnalysis,
|
||||
cycleNodeIdSet,
|
||||
actionableNodeIds,
|
||||
blockerTooltipMap,
|
||||
blockerAnalysis,
|
||||
chainNodeIds,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue