diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index 7d646cc..474acc6 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -16,16 +16,20 @@ import { import '@xyflow/react/dist/style.css' import FunnelNode from './nodes/FunnelNode' -import type { FlowNode, FlowEdge, FunnelNodeData } from '@/lib/types' +import OutcomeNode from './nodes/OutcomeNode' +import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types' const nodeTypes = { funnel: FunnelNode, + outcome: OutcomeNode, } -// Color palette for allocations -const COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4'] +// Colors for allocations +const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] +const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c'] const initialNodes: FlowNode[] = [ + // Main Treasury Funnel { id: 'treasury', type: 'funnel', @@ -37,17 +41,24 @@ const initialNodes: FlowNode[] = [ maxThreshold: 70000, maxCapacity: 100000, inflowRate: 1000, - outflowAllocations: [ - { targetId: 'public-goods', percentage: 40, color: COLORS[0] }, - { targetId: 'research', percentage: 35, color: COLORS[1] }, - { targetId: 'emergency', percentage: 25, color: COLORS[2] }, + // Overflow goes SIDEWAYS to other funnels + overflowAllocations: [ + { targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] }, + { targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] }, + { targetId: 'emergency', percentage: 25, color: OVERFLOW_COLORS[2] }, ], - }, + // Spending goes DOWN to outcomes + spendingAllocations: [ + { targetId: 'treasury-ops', percentage: 60, color: SPENDING_COLORS[0] }, + { targetId: 'treasury-audit', percentage: 40, color: SPENDING_COLORS[1] }, + ], + } as FunnelNodeData, }, + // Sub-funnels (receive overflow from Treasury) { id: 'public-goods', type: 'funnel', - position: { x: 100, y: 350 }, + position: { x: 50, y: 300 }, data: { label: 'Public Goods', currentValue: 45000, @@ -55,16 +66,18 @@ const initialNodes: FlowNode[] = [ maxThreshold: 50000, maxCapacity: 70000, inflowRate: 400, - outflowAllocations: [ - { targetId: 'project-alpha', percentage: 60, color: COLORS[0] }, - { targetId: 'project-beta', percentage: 40, color: COLORS[1] }, + overflowAllocations: [], + spendingAllocations: [ + { targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] }, + { targetId: 'pg-education', percentage: 30, color: SPENDING_COLORS[1] }, + { targetId: 'pg-tooling', percentage: 20, color: SPENDING_COLORS[2] }, ], - }, + } as FunnelNodeData, }, { id: 'research', type: 'funnel', - position: { x: 400, y: 350 }, + position: { x: 400, y: 300 }, data: { label: 'Research', currentValue: 28000, @@ -72,101 +85,148 @@ const initialNodes: FlowNode[] = [ maxThreshold: 45000, maxCapacity: 60000, inflowRate: 350, - outflowAllocations: [ - { targetId: 'project-gamma', percentage: 70, color: COLORS[0] }, - { targetId: 'project-beta', percentage: 30, color: COLORS[1] }, + overflowAllocations: [], + spendingAllocations: [ + { targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] }, + { targetId: 'research-papers', percentage: 30, color: SPENDING_COLORS[1] }, ], - }, + } as FunnelNodeData, }, { id: 'emergency', type: 'funnel', - position: { x: 700, y: 350 }, + position: { x: 750, y: 300 }, data: { - label: 'Emergency', + label: 'Emergency Fund', currentValue: 12000, minThreshold: 25000, maxThreshold: 60000, maxCapacity: 80000, inflowRate: 250, - outflowAllocations: [ - { targetId: 'reserve', percentage: 100, color: COLORS[0] }, + overflowAllocations: [], + spendingAllocations: [ + { targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] }, ], - }, + } as FunnelNodeData, + }, + // Outcome nodes (receive spending from funnels) + { + id: 'treasury-ops', + type: 'outcome', + position: { x: 350, y: 600 }, + data: { + label: 'Treasury Operations', + description: 'Day-to-day treasury management and reporting', + fundingReceived: 15000, + fundingTarget: 25000, + status: 'in-progress', + } as OutcomeNodeData, }, { - id: 'project-alpha', - type: 'funnel', - position: { x: 0, y: 700 }, + id: 'treasury-audit', + type: 'outcome', + position: { x: 550, y: 600 }, data: { - label: 'Project Alpha', - currentValue: 18000, - minThreshold: 10000, - maxThreshold: 30000, - maxCapacity: 40000, - inflowRate: 240, - outflowAllocations: [], - }, + label: 'Annual Audit', + description: 'Third-party financial audit and compliance', + fundingReceived: 8000, + fundingTarget: 15000, + status: 'in-progress', + } as OutcomeNodeData, }, { - id: 'project-beta', - type: 'funnel', - position: { x: 300, y: 700 }, + id: 'pg-infra', + type: 'outcome', + position: { x: -50, y: 600 }, data: { - label: 'Project Beta', - currentValue: 22000, - minThreshold: 15000, - maxThreshold: 35000, - maxCapacity: 45000, - inflowRate: 265, - outflowAllocations: [], - }, + label: 'Infrastructure', + description: 'Core infrastructure development and maintenance', + fundingReceived: 22000, + fundingTarget: 30000, + status: 'in-progress', + } as OutcomeNodeData, }, { - id: 'project-gamma', - type: 'funnel', - position: { x: 600, y: 700 }, + id: 'pg-education', + type: 'outcome', + position: { x: 100, y: 700 }, data: { - label: 'Project Gamma', - currentValue: 8000, - minThreshold: 12000, - maxThreshold: 28000, - maxCapacity: 35000, - inflowRate: 245, - outflowAllocations: [], - }, + label: 'Education Programs', + description: 'Developer education and onboarding materials', + fundingReceived: 12000, + fundingTarget: 20000, + status: 'in-progress', + } as OutcomeNodeData, }, { - id: 'reserve', - type: 'funnel', - position: { x: 900, y: 700 }, + id: 'pg-tooling', + type: 'outcome', + position: { x: 250, y: 600 }, data: { - label: 'Reserve', - currentValue: 5000, - minThreshold: 20000, - maxThreshold: 50000, - maxCapacity: 60000, - inflowRate: 250, - outflowAllocations: [], - }, + label: 'Dev Tooling', + description: 'Open-source developer tools and SDKs', + fundingReceived: 5000, + fundingTarget: 15000, + status: 'not-started', + } as OutcomeNodeData, + }, + { + id: 'research-grants', + type: 'outcome', + position: { x: 400, y: 600 }, + data: { + label: 'Research Grants', + description: 'Academic research grants for protocol improvements', + fundingReceived: 18000, + fundingTarget: 25000, + status: 'in-progress', + } as OutcomeNodeData, + }, + { + id: 'research-papers', + type: 'outcome', + position: { x: 500, y: 700 }, + data: { + label: 'Published Papers', + description: 'Peer-reviewed research publications', + fundingReceived: 8000, + fundingTarget: 10000, + status: 'in-progress', + } as OutcomeNodeData, + }, + { + id: 'emergency-response', + type: 'outcome', + position: { x: 750, y: 600 }, + data: { + label: 'Emergency Response', + description: 'Rapid response fund for critical issues', + fundingReceived: 5000, + fundingTarget: 50000, + status: 'not-started', + } as OutcomeNodeData, }, ] -// Generate edges from node allocations with proportional thickness +// Generate edges from node allocations function generateEdges(nodes: FlowNode[]): FlowEdge[] { const edges: FlowEdge[] = [] - const maxAllocation = 100 // Max percentage for scaling nodes.forEach((node) => { + if (node.type !== 'funnel') return const data = node.data as FunnelNodeData - data.outflowAllocations.forEach((alloc) => { - // Calculate stroke width: min 2px, max 12px based on percentage - const strokeWidth = 2 + (alloc.percentage / maxAllocation) * 10 + + // OVERFLOW edges - go SIDEWAYS to other funnels + data.overflowAllocations?.forEach((alloc, idx) => { + const strokeWidth = 2 + (alloc.percentage / 100) * 8 + const isLeftSide = idx % 2 === 0 edges.push({ - id: `e-${node.id}-${alloc.targetId}`, + id: `overflow-${node.id}-${alloc.targetId}`, source: node.id, target: alloc.targetId, + sourceHandle: isLeftSide ? 'overflow-left' : 'overflow-right', + targetHandle: isLeftSide ? 'inflow-right' : 'inflow-left', animated: true, style: { stroke: alloc.color, @@ -176,12 +236,42 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] { markerEnd: { type: MarkerType.ArrowClosed, color: alloc.color, - width: 15 + alloc.percentage / 10, - height: 15 + alloc.percentage / 10, + width: 12 + alloc.percentage / 10, + height: 12 + alloc.percentage / 10, }, data: { allocation: alloc.percentage, color: alloc.color, + edgeType: 'overflow' as const, + }, + type: 'smoothstep', + }) + }) + + // SPENDING edges - go DOWN to outcomes + data.spendingAllocations?.forEach((alloc) => { + const strokeWidth = 2 + (alloc.percentage / 100) * 8 + + edges.push({ + id: `spending-${node.id}-${alloc.targetId}`, + source: node.id, + target: alloc.targetId, + animated: true, + style: { + stroke: alloc.color, + strokeWidth, + opacity: 0.9, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: alloc.color, + width: 12 + alloc.percentage / 10, + height: 12 + alloc.percentage / 10, + }, + data: { + allocation: alloc.percentage, + color: alloc.color, + edgeType: 'spending' as const, }, }) }) @@ -211,23 +301,37 @@ export default function FlowCanvas() { [setEdges] ) - // Simulation effect + // Simulation effect - update funnel values and outcome funding useEffect(() => { if (!isSimulating) return const interval = setInterval(() => { setNodes((nds) => nds.map((node) => { - const data = node.data as FunnelNodeData - // Random walk for demo - const change = (Math.random() - 0.45) * 300 - return { - ...node, - data: { - ...data, - currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)), - }, + if (node.type === 'funnel') { + const data = node.data as FunnelNodeData + const change = (Math.random() - 0.45) * 300 + return { + ...node, + data: { + ...data, + currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)), + }, + } + } else if (node.type === 'outcome') { + const data = node.data as OutcomeNodeData + const change = Math.random() * 100 + const newReceived = Math.min(data.fundingTarget * 1.1, data.fundingReceived + change) + return { + ...node, + data: { + ...data, + fundingReceived: newReceived, + status: newReceived >= data.fundingTarget ? 'completed' : data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status, + }, + } } + return node }) ) }, 500) @@ -235,7 +339,7 @@ export default function FlowCanvas() { return () => clearInterval(interval) }, [isSimulating, setNodes]) - // Regenerate edges when nodes change (to update proportions if needed) + // Regenerate edges when nodes change useEffect(() => { setEdges(generateEdges(nodes)) }, [nodes, setEdges]) @@ -259,7 +363,8 @@ export default function FlowCanvas() { {/* Title Panel */}

Threshold-Based Flow Funding

-

Drag min/max handles • Line thickness = allocation %

+

Overflow → Funnels (sideways) • Spending → Outcomes (down)

+

Double-click funnels to edit • Drag thresholds to adjust

{/* Simulation Toggle */} @@ -278,31 +383,31 @@ export default function FlowCanvas() { {/* Legend */} -
Funnel Zones
+
Flow Types
-
- Overflow (above MAX) +
+ Overflow → Other Funnels
-
- Healthy (MIN to MAX) -
-
-
- Critical (below MIN) +
+ Spending → Outcomes
-
Flow Lines
+
Funnel Status
-
- Thin = small allocation +
+ Overflow (above MAX)
-
- Thick = large allocation +
+ Healthy (MIN to MAX) +
+
+
+ Critical (below MIN)
diff --git a/components/nodes/FunnelNode.tsx b/components/nodes/FunnelNode.tsx index 54a4959..99df918 100644 --- a/components/nodes/FunnelNode.tsx +++ b/components/nodes/FunnelNode.tsx @@ -5,16 +5,24 @@ import { Handle, Position } from '@xyflow/react' import type { NodeProps } from '@xyflow/react' import type { FunnelNodeData } from '@/lib/types' -// Pie chart colors -const PIE_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4'] +// Pie chart colors for spending (cool tones - going DOWN to outcomes) +const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] +// Overflow colors (warm tones - going SIDEWAYS to other funnels) +const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c'] -function FunnelNode({ data, selected }: NodeProps) { +function FunnelNode({ data, selected, id }: NodeProps) { const nodeData = data as FunnelNodeData - const { label, currentValue, maxCapacity, outflowAllocations } = nodeData + const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) const [dragging, setDragging] = useState<'min' | 'max' | null>(null) + const [isEditing, setIsEditing] = useState(false) + const [editValues, setEditValues] = useState({ + minThreshold: nodeData.minThreshold, + maxThreshold: nodeData.maxThreshold, + label: label, + }) const sliderRef = useRef(null) // Calculate status @@ -22,10 +30,10 @@ function FunnelNode({ data, selected }: NodeProps) { const isCritical = currentValue < minThreshold // Funnel dimensions - const width = 180 + const width = 200 const height = 160 - const topWidth = 160 - const bottomWidth = 40 + const topWidth = 180 + const bottomWidth = 50 const padding = 8 // Calculate Y positions @@ -88,15 +96,36 @@ function FunnelNode({ data, selected }: NodeProps) { } }, [dragging, handleSliderMouseMove, handleSliderMouseUp]) - // Pie chart calculations - const pieRadius = 24 - const pieCenter = { x: pieRadius + 4, y: pieRadius + 4 } + // Double-click to edit + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setEditValues({ + minThreshold, + maxThreshold, + label, + }) + setIsEditing(true) + }, [minThreshold, maxThreshold, label]) - const getPieSlices = () => { - if (outflowAllocations.length === 0) return [] + const handleSaveEdit = useCallback(() => { + setMinThreshold(editValues.minThreshold) + setMaxThreshold(editValues.maxThreshold) + setIsEditing(false) + }, [editValues]) - let currentAngle = -90 // Start at top - return outflowAllocations.map((alloc, idx) => { + const handleCancelEdit = useCallback(() => { + setIsEditing(false) + }, []) + + // Pie chart calculations for SPENDING (downward to outcomes) + const spendingPieRadius = 20 + const spendingPieCenter = { x: spendingPieRadius + 4, y: spendingPieRadius + 4 } + + const getSpendingPieSlices = () => { + if (spendingAllocations.length === 0) return [] + + let currentAngle = -90 + return spendingAllocations.map((alloc, idx) => { const angle = (alloc.percentage / 100) * 360 const startAngle = currentAngle const endAngle = currentAngle + angle @@ -105,215 +134,418 @@ function FunnelNode({ data, selected }: NodeProps) { const startRad = (startAngle * Math.PI) / 180 const endRad = (endAngle * Math.PI) / 180 - const x1 = pieCenter.x + pieRadius * Math.cos(startRad) - const y1 = pieCenter.y + pieRadius * Math.sin(startRad) - const x2 = pieCenter.x + pieRadius * Math.cos(endRad) - const y2 = pieCenter.y + pieRadius * Math.sin(endRad) + const x1 = spendingPieCenter.x + spendingPieRadius * Math.cos(startRad) + const y1 = spendingPieCenter.y + spendingPieRadius * Math.sin(startRad) + const x2 = spendingPieCenter.x + spendingPieRadius * Math.cos(endRad) + const y2 = spendingPieCenter.y + spendingPieRadius * Math.sin(endRad) const largeArc = angle > 180 ? 1 : 0 return { - path: `M ${pieCenter.x} ${pieCenter.y} L ${x1} ${y1} A ${pieRadius} ${pieRadius} 0 ${largeArc} 1 ${x2} ${y2} Z`, - color: alloc.color || PIE_COLORS[idx % PIE_COLORS.length], + path: `M ${spendingPieCenter.x} ${spendingPieCenter.y} L ${x1} ${y1} A ${spendingPieRadius} ${spendingPieRadius} 0 ${largeArc} 1 ${x2} ${y2} Z`, + color: alloc.color || SPENDING_COLORS[idx % SPENDING_COLORS.length], percentage: alloc.percentage, targetId: alloc.targetId, } }) } - const pieSlices = getPieSlices() + // Mini bar chart for OVERFLOW (sideways to other funnels) + const getOverflowBars = () => { + return overflowAllocations.map((alloc, idx) => ({ + color: alloc.color || OVERFLOW_COLORS[idx % OVERFLOW_COLORS.length], + percentage: alloc.percentage, + targetId: alloc.targetId, + })) + } + + const spendingSlices = getSpendingPieSlices() + const overflowBars = getOverflowBars() + const hasOverflow = overflowAllocations.length > 0 + const hasSpending = spendingAllocations.length > 0 return ( -
- {/* Top Handle - Inflow */} - + <> +
+ {/* Top Handle - Inflow from parent funnel overflow */} + - {/* Header */} -
-
- {label} - +
+ {label} + + {isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'} + +
+
+ + {/* Main content */} +
+ {/* Funnel SVG */} + + + + + + + + + + + + {/* Zone backgrounds */} + + + + + {/* Liquid fill */} + + + + + + + {/* Funnel outline */} + + + + {/* Threshold zone indicator */} + + + + + {/* Overflow particles - flying to the sides */} + {isOverflowing && ( + <> + + + + + + + + + + + + + + + + + )} + + {/* Spending flow particles - going down through the funnel */} + {hasSpending && currentValue > minThreshold && ( + <> + + + + + + + + + + + + )} + + + {/* Right side info */} +
+ {/* Spending pie chart (downward) */} + {hasSpending && ( +
+ Spend + + {spendingSlices.map((slice, idx) => ( + + ))} + + + ↓ + + +
+ )} + + {/* Overflow bars (sideways) */} + {hasOverflow && ( +
+ Overflow +
+ {overflowBars.map((bar, idx) => ( +
+ ))} +
+ → ← +
+ )} +
+
+ + {/* Value display */} +
+ - {isOverflowing ? 'OVERFLOW' : isCritical ? 'CRITICAL' : 'HEALTHY'} + ${Math.floor(currentValue).toLocaleString()}
+ + {/* Dual range slider */} +
+
+ MIN: ${(minThreshold/1000).toFixed(0)}k + MAX: ${(maxThreshold/1000).toFixed(0)}k +
+
e.stopPropagation()} + > + {/* Track background */} +
+
+
+
+
+ + {/* Min handle */} +
handleSliderMouseDown(e, 'min')} + /> + + {/* Max handle */} +
handleSliderMouseDown(e, 'max')} + /> +
+
+ Double-click to edit +
+
+ + {/* Bottom Handle - Spending outflow to outcomes */} + + + {/* Side Handles - Overflow to other funnels */} + + + + {/* Side Handles - Inflow from other funnel overflow */} + +
- {/* Main content - Funnel and Pie side by side */} -
- {/* Funnel SVG */} - - - - - - - - - - + {/* Edit Modal */} + {isEditing && ( +
+
e.stopPropagation()} + > +

Edit {label}

- {/* Zone backgrounds */} - - - - - {/* Liquid fill */} - - - - - - - {/* Funnel outline */} - - - - {/* Threshold zone indicator (single bar on right side) */} - - - - - {/* Overflow particles */} - {isOverflowing && ( - <> - - - - - - - - - - - - )} - - - {/* Pie chart for outflow allocation */} - {outflowAllocations.length > 0 && ( -
- Outflow - - {pieSlices.map((slice, idx) => ( - - ))} - - - {/* Mini legend */} -
- {outflowAllocations.slice(0, 3).map((alloc, idx) => ( -
-
- {alloc.percentage}% +
+ {/* Min Threshold */} +
+ +
+ setEditValues(v => ({ ...v, minThreshold: Number(e.target.value) }))} + className="flex-1" + /> + + ${(editValues.minThreshold / 1000).toFixed(0)}k +
- ))} +
+ + {/* Max Threshold */} +
+ +
+ setEditValues(v => ({ ...v, maxThreshold: Number(e.target.value) }))} + className="flex-1" + /> + + ${(editValues.maxThreshold / 1000).toFixed(0)}k + +
+
+ + {/* Visual preview */} +
+
Threshold Range
+
+
+
+
+
+ 0 + ${(maxCapacity / 1000).toFixed(0)}k +
+
+ + {/* Overflow allocations info */} + {hasOverflow && ( +
+
Overflow Allocations (to other funnels)
+
+ {overflowAllocations.map((alloc, idx) => ( +
+
+ {alloc.targetId} + {alloc.percentage}% +
+ ))} +
+
+ )} + + {/* Spending allocations info */} + {hasSpending && ( +
+
Spending Allocations (to outcomes)
+
+ {spendingAllocations.map((alloc, idx) => ( +
+
+ {alloc.targetId} + {alloc.percentage}% +
+ ))} +
+
+ )} +
+ + {/* Buttons */} +
+ +
- )} -
- - {/* Value display */} -
- - ${Math.floor(currentValue).toLocaleString()} - -
- - {/* Dual range slider */} -
-
- MIN: ${(minThreshold/1000).toFixed(0)}k - MAX: ${(maxThreshold/1000).toFixed(0)}k
-
e.stopPropagation()} - > - {/* Track background */} -
- {/* Red zone (0 to min) */} -
- {/* Green zone (min to max) */} -
- {/* Amber zone (max to capacity) */} -
-
- - {/* Min handle */} -
handleSliderMouseDown(e, 'min')} - /> - - {/* Max handle */} -
handleSliderMouseDown(e, 'max')} - /> -
-
- - {/* Bottom Handle - Outflow */} - - - {/* Side Handles - Overflow */} - - -
+ )} + ) } diff --git a/components/nodes/OutcomeNode.tsx b/components/nodes/OutcomeNode.tsx new file mode 100644 index 0000000..c93ee8d --- /dev/null +++ b/components/nodes/OutcomeNode.tsx @@ -0,0 +1,97 @@ +'use client' + +import { memo } from 'react' +import { Handle, Position } from '@xyflow/react' +import type { NodeProps } from '@xyflow/react' +import type { OutcomeNodeData } from '@/lib/types' + +function OutcomeNode({ data, selected }: NodeProps) { + const nodeData = data as OutcomeNodeData + const { label, description, fundingReceived, fundingTarget, status } = nodeData + + const progress = fundingTarget > 0 ? Math.min(100, (fundingReceived / fundingTarget) * 100) : 0 + const isFunded = fundingReceived >= fundingTarget + const isPartial = fundingReceived > 0 && fundingReceived < fundingTarget + + // Status colors + const statusColors = { + 'not-started': { bg: 'bg-slate-100', text: 'text-slate-600', border: 'border-slate-300' }, + 'in-progress': { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' }, + 'completed': { bg: 'bg-emerald-100', text: 'text-emerald-700', border: 'border-emerald-300' }, + 'blocked': { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' }, + } + + const colors = statusColors[status] || statusColors['not-started'] + + return ( +
+ {/* Input Handle - Top */} + + + {/* Header with icon */} +
+
+
+ + + +
+ {label} +
+
+ + {/* Body */} +
+ {/* Description */} + {description && ( +

{description}

+ )} + + {/* Status badge */} +
+ + {status.replace('-', ' ')} + + {isFunded && ( + + + + )} +
+ + {/* Funding progress */} +
+
+ Funding + + ${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()} + +
+
+
+
+
+ {progress.toFixed(0)}% +
+
+
+
+ ) +} + +export default memo(OutcomeNode) diff --git a/lib/types.ts b/lib/types.ts index 55c9ba7..c5425ef 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,6 +1,14 @@ import type { Node, Edge } from '@xyflow/react' -export interface OutflowAllocation { +// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold +export interface OverflowAllocation { + targetId: string + percentage: number // 0-100 + color: string +} + +// Spending allocation - funds flowing DOWN to OUTCOMES/OUTPUTS +export interface SpendingAllocation { targetId: string percentage: number // 0-100 color: string @@ -13,15 +21,28 @@ export interface FunnelNodeData { maxThreshold: number maxCapacity: number inflowRate: number - outflowAllocations: OutflowAllocation[] + // Overflow goes SIDEWAYS to other funnels + overflowAllocations: OverflowAllocation[] + // Spending goes DOWN to outcomes/outputs + spendingAllocations: SpendingAllocation[] [key: string]: unknown } -export type FlowNode = Node +export interface OutcomeNodeData { + label: string + description?: string + fundingReceived: number + fundingTarget: number + status: 'not-started' | 'in-progress' | 'completed' | 'blocked' + [key: string]: unknown +} + +export type FlowNode = Node export interface FlowEdgeData { allocation: number // percentage 0-100 color: string + edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward [key: string]: unknown }