From bd77cd91135ac2f9de2e49a7869ab4aab26b909c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 27 Feb 2026 13:29:43 -0800 Subject: [PATCH] feat: add source nodes and tiered outcome phases to TBFF canvas Source nodes represent funding origins (Card/Safe/rIdentity) with configurable flow rates and allocation edges to funnels. Outcome nodes now support phased funding tiers with progressive task unlocking as funding thresholds are reached. Co-Authored-By: Claude Opus 4.6 --- components/FlowCanvas.tsx | 240 +++++++++++++++++-- components/nodes/OutcomeNode.tsx | 269 ++++++++++++++++++++- components/nodes/SourceNode.tsx | 392 +++++++++++++++++++++++++++++++ lib/presets.ts | 46 +++- lib/simulation.ts | 41 +++- lib/types.ts | 44 +++- 6 files changed, 994 insertions(+), 38 deletions(-) create mode 100644 components/nodes/SourceNode.tsx diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index 1d0bbf8..aae439e 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -18,17 +18,19 @@ import '@xyflow/react/dist/style.css' import FunnelNode from './nodes/FunnelNode' import OutcomeNode from './nodes/OutcomeNode' +import SourceNode from './nodes/SourceNode' import AllocationEdge from './edges/AllocationEdge' import StreamEdge from './edges/StreamEdge' import IntegrationPanel from './IntegrationPanel' import { ConnectionContext, type ConnectingFrom } from './ConnectionContext' -import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types' -import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets' +import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, SourceNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types' +import { SPENDING_COLORS, OVERFLOW_COLORS, SOURCE_COLORS } from '@/lib/presets' import { simulateTick } from '@/lib/simulation' const nodeTypes = { funnel: FunnelNode, outcome: OutcomeNode, + source: SourceNode, } const edgeTypes = { @@ -39,12 +41,20 @@ const edgeTypes = { // Generate edges with proportional Sankey-style widths function generateEdges( nodes: FlowNode[], - onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void + onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending' | 'source', delta: number) => void ): FlowEdge[] { const edges: FlowEdge[] = [] const flowValues: number[] = [] nodes.forEach((node) => { + if (node.type === 'source') { + const data = node.data as SourceNodeData + const rate = data.flowRate || 1 + data.targetAllocations?.forEach((alloc) => { + flowValues.push((alloc.percentage / 100) * rate) + }) + return + } if (node.type !== 'funnel') return const data = node.data as FunnelNodeData const rate = data.inflowRate || 1 @@ -61,6 +71,48 @@ function generateEdges( const MIN_WIDTH = 3 const MAX_WIDTH = 24 + // Source node edges + nodes.forEach((node) => { + if (node.type !== 'source') return + const data = node.data as SourceNodeData + const rate = data.flowRate || 1 + const allocCount = data.targetAllocations?.length ?? 0 + + data.targetAllocations?.forEach((alloc) => { + const flowValue = (alloc.percentage / 100) * rate + const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH) + + edges.push({ + id: `source-${node.id}-${alloc.targetId}`, + source: node.id, + target: alloc.targetId, + sourceHandle: 'source-out', + animated: true, + style: { + stroke: alloc.color, + strokeWidth, + opacity: 0.8, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: alloc.color, + width: 16, + height: 16, + }, + data: { + allocation: alloc.percentage, + color: alloc.color, + edgeType: 'source' as const, + sourceId: node.id, + targetId: alloc.targetId, + siblingCount: allocCount, + onAdjust, + }, + type: 'allocation', + }) + }) + }) + nodes.forEach((node) => { if (node.type !== 'funnel') return const data = node.data as FunnelNodeData @@ -208,9 +260,46 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra // Adjust allocation percentage inline from edge +/- buttons const onAdjustAllocation = useCallback( - (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => { + (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending' | 'source', delta: number) => { setNodes((nds) => nds.map((node) => { - if (node.id !== sourceId || node.type !== 'funnel') return node + if (node.id !== sourceId) return node + + // Source node allocation adjustment + if (edgeType === 'source' && node.type === 'source') { + const data = node.data as SourceNodeData + const allocs = [...data.targetAllocations] + if (allocs.length <= 1) return node + + const idx = allocs.findIndex(a => a.targetId === targetId) + if (idx === -1) return node + + const current = allocs[idx].percentage + const newPct = Math.max(5, Math.min(95, current + delta)) + const actualDelta = newPct - current + if (actualDelta === 0) return node + + const siblings = allocs.filter((_, i) => i !== idx) + const siblingTotal = siblings.reduce((s, a) => s + a.percentage, 0) + + const updated = allocs.map((a, i) => { + if (i === idx) return { ...a, percentage: newPct } + const share = siblingTotal > 0 ? a.percentage / siblingTotal : 1 / siblings.length + return { ...a, percentage: Math.max(1, Math.round(a.percentage - actualDelta * share)) } + }) + + const sum = updated.reduce((s, a) => s + a.percentage, 0) + if (sum !== 100 && updated.length > 1) { + const diff = 100 - sum + const largestSibIdx = updated.reduce((best, a, i) => + i !== idx && a.percentage > updated[best].percentage ? i : best, idx === 0 ? 1 : 0) + updated[largestSibIdx] = { ...updated[largestSibIdx], percentage: updated[largestSibIdx].percentage + diff } + } + + return { ...node, data: { ...data, targetAllocations: updated } } + } + + // Funnel node allocation adjustment + if (node.type !== 'funnel') return node const data = node.data as FunnelNodeData const allocKey = edgeType === 'overflow' ? 'overflowAllocations' : 'spendingAllocations' const allocs = [...data[allocKey]] @@ -256,20 +345,29 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra // Smart edge regeneration const allocationsKey = useMemo(() => { - return JSON.stringify( - nodes - .filter(n => n.type === 'funnel') - .map(n => { - const d = n.data as FunnelNodeData - return { - id: n.id, - overflow: d.overflowAllocations, - spending: d.spendingAllocations, - streams: d.streamAllocations, - rate: d.inflowRate, - } - }) - ) + const funnels = nodes + .filter(n => n.type === 'funnel') + .map(n => { + const d = n.data as FunnelNodeData + return { + id: n.id, + overflow: d.overflowAllocations, + spending: d.spendingAllocations, + streams: d.streamAllocations, + rate: d.inflowRate, + } + }) + const sources = nodes + .filter(n => n.type === 'source') + .map(n => { + const d = n.data as SourceNodeData + return { + id: n.id, + allocations: d.targetAllocations, + rate: d.flowRate, + } + }) + return JSON.stringify({ funnels, sources }) }, [nodes]) useEffect(() => { @@ -281,11 +379,42 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra (params: Connection) => { if (!params.source || !params.target) return + const isSourceOut = params.sourceHandle === 'source-out' const isOverflow = params.sourceHandle?.startsWith('outflow') const isSpending = params.sourceHandle === 'spending-out' const isStream = params.sourceHandle === 'stream-out' - if (!isOverflow && !isSpending && !isStream) return + if (!isSourceOut && !isOverflow && !isSpending && !isStream) return + + // Source node connections + if (isSourceOut) { + setNodes((nds) => nds.map((node) => { + if (node.id !== params.source || node.type !== 'source') return node + const data = node.data as SourceNodeData + const existing = data.targetAllocations || [] + if (existing.some(a => a.targetId === params.target)) return node + const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1)) + const redistributed = existing.map(a => ({ + ...a, + percentage: Math.floor(a.percentage * existing.length / (existing.length + 1)) + })) + return { + ...node, + data: { + ...data, + targetAllocations: [ + ...redistributed, + { + targetId: params.target!, + percentage: newPct, + color: SOURCE_COLORS[existing.length % SOURCE_COLORS.length], + }, + ], + }, + } + })) + return + } setNodes((nds) => nds.map((node) => { if (node.id !== params.source || node.type !== 'funnel') return node @@ -371,6 +500,24 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra const newTargetId = newConnection.target if (oldTargetId === newTargetId) return + // Source edges: reconnect source allocation + if (edgeData.edgeType === 'source') { + setNodes((nds) => nds.map((node) => { + if (node.id !== oldEdge.source || node.type !== 'source') return node + const data = node.data as SourceNodeData + return { + ...node, + data: { + ...data, + targetAllocations: data.targetAllocations.map(a => + a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a + ), + }, + } + })) + return + } + // Stream edges: reconnect stream allocation if (oldEdge.type === 'stream') { setNodes((nds) => nds.map((node) => { @@ -438,6 +585,28 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra if (change.type === 'remove') { const edge = edgesRef.current.find(e => e.id === change.id) if (edge?.data) { + const allocData = edge.data as AllocationEdgeData + + // Source edge removal + if (allocData.edgeType === 'source') { + setNodes((nds) => nds.map((node) => { + if (node.id !== edge.source || node.type !== 'source') return node + const data = node.data as SourceNodeData + const filtered = data.targetAllocations.filter(a => a.targetId !== edge.target) + const total = filtered.reduce((s, a) => s + a.percentage, 0) + return { + ...node, + data: { + ...data, + targetAllocations: total > 0 + ? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) + : filtered, + }, + } + })) + return + } + setNodes((nds) => nds.map((node) => { if (node.id !== edge.source || node.type !== 'funnel') return node const data = node.data as FunnelNodeData @@ -453,7 +622,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra } } - const allocData = edge.data as AllocationEdgeData if (allocData.edgeType === 'overflow') { const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target) const total = filtered.reduce((s, a) => s + a.percentage, 0) @@ -539,6 +707,26 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra ]) }, [setNodes, screenToFlowPosition]) + // Add source node at viewport center + const addSource = useCallback(() => { + const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 - 100 }) + const newId = `source-${Date.now()}` + setNodes((nds) => [ + ...nds, + { + id: newId, + type: 'source', + position: pos, + data: { + label: 'New Source', + sourceType: 'unconfigured', + flowRate: 0, + targetAllocations: [], + } as SourceNodeData, + }, + ]) + }, [setNodes, screenToFlowPosition]) + // Simulation — real flow logic (inflow → overflow → spending → outcomes) useEffect(() => { if (!isSimulating) return @@ -603,6 +791,12 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra > Link Data + + + {/* Phase tier segmented bar */} +
+ {sortedPhases.map((phase, i) => { + const isUnlocked = fundingReceived >= phase.fundingThreshold + const allDone = phase.tasks.every(t => t.completed) + return ( +
+ ) + })} +
+
+ {sortedPhases.map((phase, phaseIdx) => { + const isUnlocked = fundingReceived >= phase.fundingThreshold + const allDone = phase.tasks.every(t => t.completed) + const completedCount = phase.tasks.filter(t => t.completed).length + const isOpen = expandedPhaseIdx === phaseIdx || (expandedPhaseIdx === null && autoExpandIdx === phaseIdx) + + return ( +
+ {/* Phase header */} + + + {/* Phase content */} + {isOpen && ( +
+ {/* Funding progress for this phase */} +
+
+
+
+ + ${Math.floor(Math.min(fundingReceived, phase.fundingThreshold)).toLocaleString()} / ${phase.fundingThreshold.toLocaleString()} + +
+ + {/* Tasks */} +
+ {phase.tasks.map((task, taskIdx) => ( + + ))} +
+ + {/* Edit mode: add task */} + {isEditingPhases && isUnlocked && ( +
+ +
+ )} +
+ )} +
+ ) + })} +
+ + {/* Add Phase button (edit mode) */} + {isEditingPhases && ( + + )} +
+ )} + + {/* Legacy Milestones (backward compat when no phases) */} + {(!phases || phases.length === 0) && nodeData.milestones && nodeData.milestones.length > 0 && (
Milestones
diff --git a/components/nodes/SourceNode.tsx b/components/nodes/SourceNode.tsx new file mode 100644 index 0000000..49e5339 --- /dev/null +++ b/components/nodes/SourceNode.tsx @@ -0,0 +1,392 @@ +'use client' + +import { memo, useState, useCallback, useMemo } from 'react' +import { Handle, Position, useReactFlow } from '@xyflow/react' +import type { NodeProps } from '@xyflow/react' +import type { SourceNodeData } from '@/lib/types' +import { useAuthStore } from '@/lib/auth' + +const CHAIN_OPTIONS = [ + { id: 1, name: 'Ethereum' }, + { id: 10, name: 'Optimism' }, + { id: 100, name: 'Gnosis' }, + { id: 137, name: 'Polygon' }, + { id: 8453, name: 'Base' }, +] + +const CURRENCIES = ['USD', 'EUR', 'GBP'] + +type SourceType = SourceNodeData['sourceType'] + +const SOURCE_TYPE_META: Record = { + card: { label: 'Credit Card', color: 'violet' }, + safe_wallet: { label: 'Safe / Wallet', color: 'cyan' }, + ridentity: { label: 'rIdentity', color: 'purple' }, + unconfigured: { label: 'Unconfigured', color: 'slate' }, +} + +function SourceTypeIcon({ type, className = 'w-4 h-4' }: { type: SourceType; className?: string }) { + switch (type) { + case 'card': + return ( + + + + ) + case 'safe_wallet': + return ( + + + + ) + case 'ridentity': + return ( + + + + ) + default: // unconfigured + return ( + + + + ) + } +} + +function SourceNode({ data, selected, id }: NodeProps) { + const nodeData = data as SourceNodeData + const { label, sourceType, flowRate, targetAllocations = [] } = nodeData + const [isEditing, setIsEditing] = useState(false) + const { setNodes } = useReactFlow() + const { isAuthenticated, did, login } = useAuthStore() + + // Edit form state + const [editLabel, setEditLabel] = useState(label) + const [editSourceType, setEditSourceType] = useState(sourceType) + const [editFlowRate, setEditFlowRate] = useState(flowRate) + const [editCurrency, setEditCurrency] = useState(nodeData.transakConfig?.fiatCurrency || 'USD') + const [editDefaultAmount, setEditDefaultAmount] = useState(nodeData.transakConfig?.defaultAmount?.toString() || '') + const [editWalletAddress, setEditWalletAddress] = useState(nodeData.walletAddress || '') + const [editChainId, setEditChainId] = useState(nodeData.chainId || 1) + const [editSafeAddress, setEditSafeAddress] = useState(nodeData.safeAddress || '') + + const meta = SOURCE_TYPE_META[sourceType] + const isUnconfigured = sourceType === 'unconfigured' + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setEditLabel(label) + setEditSourceType(sourceType) + setEditFlowRate(flowRate) + setEditCurrency(nodeData.transakConfig?.fiatCurrency || 'USD') + setEditDefaultAmount(nodeData.transakConfig?.defaultAmount?.toString() || '') + setEditWalletAddress(nodeData.walletAddress || '') + setEditChainId(nodeData.chainId || 1) + setEditSafeAddress(nodeData.safeAddress || '') + setIsEditing(true) + }, [label, sourceType, flowRate, nodeData]) + + const handleSave = useCallback(() => { + setNodes((nds) => nds.map((node) => { + if (node.id !== id) return node + const updated: SourceNodeData = { + ...(node.data as SourceNodeData), + label: editLabel || 'Source', + sourceType: editSourceType, + flowRate: editFlowRate, + } + if (editSourceType === 'card') { + updated.transakConfig = { + fiatCurrency: editCurrency, + defaultAmount: editDefaultAmount ? parseFloat(editDefaultAmount) : undefined, + } + } + if (editSourceType === 'safe_wallet') { + updated.walletAddress = editWalletAddress + updated.chainId = editChainId + updated.safeAddress = editSafeAddress + } + if (editSourceType === 'ridentity') { + updated.encryptIdUserId = did || undefined + } + return { ...node, data: updated } + })) + setIsEditing(false) + }, [id, editLabel, editSourceType, editFlowRate, editCurrency, editDefaultAmount, editWalletAddress, editChainId, editSafeAddress, did, setNodes]) + + const handleClose = useCallback(() => { + setIsEditing(false) + }, []) + + // Allocation bar segments + const allocTotal = useMemo(() => + targetAllocations.reduce((s, a) => s + a.percentage, 0), + [targetAllocations] + ) + + return ( + <> +
+ {/* Header */} +
+
+
+ +
+
+ {label} + + {meta.label} + +
+
+
+ + {/* Body */} +
+ {/* Flow rate */} +
+ Flow Rate + + ${flowRate.toLocaleString()}/mo + +
+ + {/* Allocation bars */} + {targetAllocations.length > 0 && ( +
+ Allocations +
+ {targetAllocations.map((alloc, i) => ( +
0 ? (alloc.percentage / allocTotal) * 100 : 0}%`, + backgroundColor: alloc.color, + }} + title={`${alloc.percentage}% → ${alloc.targetId}`} + /> + ))} +
+
+ {targetAllocations.map((alloc) => ( + + {alloc.percentage}% + + ))} +
+
+ )} + + {/* Hint */} + {isUnconfigured && ( +
+ Double-click to configure +
+ )} + + {!isUnconfigured && targetAllocations.length === 0 && ( +
+ Drag from handle below to connect to funnels +
+ )} +
+ + {/* Bottom handle - connects to funnels */} + +
+ + {/* Edit Modal */} + {isEditing && ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+

Configure Source

+
+ +
+ + {/* Source Type Picker */} +
+ +
+ {(['card', 'safe_wallet', 'ridentity'] as SourceType[]).map((type) => { + const typeMeta = SOURCE_TYPE_META[type] + const isSelected = editSourceType === type + return ( + + ) + })} +
+
+ + {/* Label */} +
+ + setEditLabel(e.target.value)} + className="w-full text-sm px-3 py-2 border border-slate-200 rounded-lg text-slate-800" + placeholder="Source name..." + /> +
+ + {/* Flow Rate */} +
+ + setEditFlowRate(Number(e.target.value))} + className="w-full text-sm px-3 py-2 border border-slate-200 rounded-lg text-slate-800 font-mono" + min="0" + step="100" + /> +
+ + {/* Type-specific config */} + {editSourceType === 'card' && ( +
+ Card Settings (Transak) + + + + setEditDefaultAmount(e.target.value)} + placeholder="100" + className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded text-slate-800" + min="0" + /> +
+ )} + + {editSourceType === 'safe_wallet' && ( +
+ Safe / Wallet Settings + + setEditWalletAddress(e.target.value)} + placeholder="0x..." + className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800 font-mono" + /> + + setEditSafeAddress(e.target.value)} + placeholder="0x..." + className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800 font-mono" + /> + + +
+ )} + + {editSourceType === 'ridentity' && ( +
+ rIdentity (EncryptID) + {isAuthenticated ? ( +
+ + Connected + {did && ( + {did.slice(0, 20)}... + )} +
+ ) : ( + + )} +
+ )} + + {/* Save / Cancel */} +
+ + +
+
+
+ )} + + ) +} + +export default memo(SourceNode) diff --git a/lib/presets.ts b/lib/presets.ts index 2e487df..6a7504f 100644 --- a/lib/presets.ts +++ b/lib/presets.ts @@ -1,11 +1,27 @@ -import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types' +import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types' // Colors for allocations export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] export const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c'] +export const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9'] -// Demo preset: Treasury → 3 sub-funnels → 7 outcomes +// Demo preset: Source → Treasury → 3 sub-funnels → 7 outcomes export const demoNodes: FlowNode[] = [ + // Source node (top, above Treasury) + { + id: 'source-donations', + type: 'source', + position: { x: 650, y: -250 }, + data: { + label: 'Donations', + sourceType: 'card', + flowRate: 1500, + targetAllocations: [ + { targetId: 'treasury', percentage: 100, color: SOURCE_COLORS[0] }, + ], + transakConfig: { fiatCurrency: 'USD', defaultAmount: 100 }, + } as SourceNodeData, + }, // Main Treasury Funnel (top center) { id: 'treasury', @@ -94,6 +110,32 @@ export const demoNodes: FlowNode[] = [ fundingReceived: 22000, fundingTarget: 30000, status: 'in-progress', + phases: [ + { + label: 'Phase 1: Research', + fundingThreshold: 5000, + tasks: [ + { label: 'Requirements analysis', completed: true }, + { label: 'Architecture design', completed: true }, + ], + }, + { + label: 'Phase 2: Build', + fundingThreshold: 20000, + tasks: [ + { label: 'Core implementation', completed: false }, + { label: 'Testing suite', completed: false }, + ], + }, + { + label: 'Phase 3: Deploy', + fundingThreshold: 30000, + tasks: [ + { label: 'Staging deployment', completed: false }, + { label: 'Production launch', completed: false }, + ], + }, + ], } as OutcomeNodeData, }, { diff --git a/lib/simulation.ts b/lib/simulation.ts index 499ee06..f4ab003 100644 --- a/lib/simulation.ts +++ b/lib/simulation.ts @@ -5,7 +5,7 @@ * inflow → overflow distribution → spending drain → outcome accumulation */ -import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types' +import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types' export interface SimulationConfig { tickDivisor: number // inflowRate divided by this per tick @@ -32,6 +32,17 @@ export function simulateTick( .filter((n) => n.type === 'funnel') .sort((a, b) => a.position.y - b.position.y) + // Source nodes: sum allocations into funnel inflow + const sourceInflow = new Map() + nodes.forEach((node) => { + if (node.type !== 'source') return + const data = node.data as SourceNodeData + for (const alloc of data.targetAllocations) { + const share = (data.flowRate / tickDivisor) * (alloc.percentage / 100) + sourceInflow.set(alloc.targetId, (sourceInflow.get(alloc.targetId) ?? 0) + share) + } + }) + // Accumulators for inter-node transfers const overflowIncoming = new Map() const spendingIncoming = new Map() @@ -43,8 +54,8 @@ export function simulateTick( const src = node.data as FunnelNodeData const data: FunnelNodeData = { ...src } - // 1. Natural inflow - let value = data.currentValue + data.inflowRate / tickDivisor + // 1. Natural inflow + source node inflow + let value = data.currentValue + data.inflowRate / tickDivisor + (sourceInflow.get(node.id) ?? 0) // 2. Overflow received from upstream funnels value += overflowIncoming.get(node.id) ?? 0 @@ -111,10 +122,26 @@ export function simulateTick( ) let newStatus = data.status - if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== 'blocked') { - newStatus = 'completed' - } else if (newReceived > 0 && newStatus === 'not-started') { - newStatus = 'in-progress' + if (newStatus === 'blocked') { + // Don't auto-change blocked status + } else if (data.phases && data.phases.length > 0) { + // Phase-aware status + const anyUnlocked = data.phases.some(p => newReceived >= p.fundingThreshold) + const allUnlocked = data.phases.every(p => newReceived >= p.fundingThreshold) + const allTasksDone = data.phases.every(p => p.tasks.every(t => t.completed)) + + if (allUnlocked && allTasksDone) { + newStatus = 'completed' + } else if (anyUnlocked) { + newStatus = 'in-progress' + } + } else { + // Fallback: no phases defined + if (data.fundingTarget > 0 && newReceived >= data.fundingTarget) { + newStatus = 'completed' + } else if (newReceived > 0 && newStatus === 'not-started') { + newStatus = 'in-progress' + } } return { diff --git a/lib/types.ts b/lib/types.ts index 268d826..28a3c7d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -85,6 +85,43 @@ export interface FundingSource { lastUsedAt?: number; } +// ─── Source Node Types ────────────────────────────────────── + +export interface SourceAllocation { + targetId: string + percentage: number + color: string +} + +export interface SourceNodeData { + label: string + sourceType: 'card' | 'safe_wallet' | 'ridentity' | 'unconfigured' + flowRate: number + targetAllocations: SourceAllocation[] + // Card-specific + transakConfig?: { fiatCurrency: string; defaultAmount?: number } + // Safe/Wallet-specific + walletAddress?: string + chainId?: number + safeAddress?: string + // rIdentity-specific + encryptIdUserId?: string + [key: string]: unknown +} + +// ─── Outcome Phase Types ──────────────────────────────────── + +export interface PhaseTask { + label: string + completed: boolean +} + +export interface OutcomePhase { + label: string // e.g., "Phase 1: Research" + fundingThreshold: number // funding level to unlock this phase + tasks: PhaseTask[] +} + // ─── Core Flow Types ───────────────────────────────────────── // Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold @@ -126,6 +163,7 @@ export interface OutcomeNodeData { fundingReceived: number fundingTarget: number status: 'not-started' | 'in-progress' | 'completed' | 'blocked' + phases?: OutcomePhase[] // ordered by fundingThreshold ascending // Integration metadata source?: IntegrationSource // Optional detail fields @@ -134,16 +172,16 @@ export interface OutcomeNodeData { [key: string]: unknown } -export type FlowNode = Node +export type FlowNode = Node export interface AllocationEdgeData { allocation: number // percentage 0-100 color: string - edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward + edgeType: 'overflow' | 'spending' | 'source' // overflow = sideways, spending = downward, source = top-down from source sourceId: string targetId: string siblingCount: number // how many allocations in this group - onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void + onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending' | 'source', delta: number) => void [key: string]: unknown }