diff --git a/app/globals.css b/app/globals.css index 13d40b8..d901bdf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -25,3 +25,43 @@ body { text-wrap: balance; } } + +/* ─── Budget River Animations ─────────────────────────── */ + +@keyframes waterFlow { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100%); } +} + +@keyframes riverCurrent { + 0% { stroke-dashoffset: 0; } + 100% { stroke-dashoffset: -24; } +} + +@keyframes droplet { + 0% { transform: translateY(0) scale(1); opacity: 0.8; } + 80% { opacity: 0.4; } + 100% { transform: translateY(50px) scale(0.2); opacity: 0; } +} + +@keyframes ripple { + 0% { r: 2; opacity: 0.6; } + 100% { r: 14; opacity: 0; } +} + +@keyframes waveFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } +} + +@keyframes poolWave { + 0% { d: path("M0,8 Q15,4 30,8 T60,8 T90,8 V20 H0 Z"); } + 50% { d: path("M0,8 Q15,12 30,8 T60,8 T90,8 V20 H0 Z"); } + 100% { d: path("M0,8 Q15,4 30,8 T60,8 T90,8 V20 H0 Z"); } +} + +@keyframes shimmer { + 0% { opacity: 0.3; } + 50% { opacity: 0.7; } + 100% { opacity: 0.3; } +} diff --git a/app/page.tsx b/app/page.tsx index 1cf9b88..4f6d847 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,6 +20,12 @@ export default function Home() { > Demo + + River View + Try the Demo + + River View + import('@/components/BudgetRiver'), { + ssr: false, + loading: () => ( +
+
+
+ Loading river view... +
+
+ ), +}) + +export default function RiverPage() { + const [mode, setMode] = useState<'demo' | 'live'>('demo') + const [nodes, setNodes] = useState(demoNodes) + const [isSimulating, setIsSimulating] = useState(true) + const [showConnect, setShowConnect] = useState(false) + const [safeAddress, setSafeAddress] = useState('') + const [connecting, setConnecting] = useState(false) + const [connectedChains, setConnectedChains] = useState([]) + const [statusMessage, setStatusMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null) + const [deployedFlow, setDeployedFlow] = useState(null) + + // Load saved address + useEffect(() => { + if (typeof window === 'undefined') return + const saved = localStorage.getItem('rfunds-owner-address') + if (saved) setSafeAddress(saved) + }, []) + + const showStatus = useCallback((text: string, type: 'success' | 'error') => { + setStatusMessage({ text, type }) + setTimeout(() => setStatusMessage(null), 4000) + }, []) + + const handleConnect = useCallback(async () => { + if (!safeAddress.trim()) return + setConnecting(true) + try { + localStorage.setItem('rfunds-owner-address', safeAddress.trim()) + const detected = await detectSafeChains(safeAddress.trim()) + if (detected.length === 0) { + showStatus('No Safe found on supported chains (Gnosis, Optimism)', 'error') + setConnecting(false) + return + } + + const chainNames = detected.map(d => d.chain.name) + setConnectedChains(chainNames) + + // Fetch balances from all detected chains and create funnel nodes + const allFunnelNodes: FlowNode[] = [] + for (const chain of detected) { + const balances = await getBalances(safeAddress.trim(), chain.chainId) + const funnels = safeBalancesToFunnels( + balances, + safeAddress.trim(), + chain.chainId, + { x: allFunnelNodes.length * 280, y: 100 } + ) + allFunnelNodes.push(...funnels) + } + + if (allFunnelNodes.length === 0) { + showStatus('Safe found but no token balances > $1', 'error') + setConnecting(false) + return + } + + setNodes(allFunnelNodes) + setMode('live') + setIsSimulating(false) + setShowConnect(false) + showStatus(`Connected: ${allFunnelNodes.length} tokens across ${chainNames.join(', ')}`, 'success') + } catch (err) { + showStatus(`Connection failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error') + } finally { + setConnecting(false) + } + }, [safeAddress, showStatus]) + + const handleLoadFlow = useCallback(async () => { + if (!safeAddress.trim()) { + showStatus('Enter a wallet address first', 'error') + return + } + try { + const flows = await listFlows(safeAddress.trim()) + if (flows.length === 0) { + showStatus('No deployed flows found for this address', 'error') + return + } + + // Load the most recent active flow + const activeFlow = flows.find(f => f.status === 'active') || flows[0] + const flow = await getFlow(activeFlow.id) + setDeployedFlow(flow) + + // Convert backend flow to visual nodes + const funnelNodes: FlowNode[] = flow.funnels.map((f, i) => ({ + id: f.id, + type: 'funnel' as const, + position: { x: i * 280, y: 100 }, + data: { + label: f.name, + currentValue: fromSmallestUnit(f.balance), + minThreshold: fromSmallestUnit(f.minThreshold), + maxThreshold: fromSmallestUnit(f.maxThreshold), + maxCapacity: fromSmallestUnit(f.maxThreshold) * 1.5, + inflowRate: f.inflowRate ? fromSmallestUnit(f.inflowRate) : 0, + overflowAllocations: f.overflowAllocations.map((a, j) => ({ + targetId: a.targetId, + percentage: a.percentage, + color: ['#f59e0b', '#ef4444', '#f97316'][j % 3], + })), + spendingAllocations: f.spendingAllocations.map((a, j) => ({ + targetId: a.targetId, + percentage: a.percentage, + color: ['#3b82f6', '#8b5cf6', '#ec4899'][j % 3], + })), + } as FunnelNodeData, + })) + + const outcomeNodes: FlowNode[] = flow.outcomes.map((o, i) => ({ + id: o.id, + type: 'outcome' as const, + position: { x: i * 250, y: 600 }, + data: { + label: o.name, + description: o.description || '', + fundingReceived: fromSmallestUnit(o.currentAmount), + fundingTarget: fromSmallestUnit(o.targetAmount), + status: o.status === 'completed' ? 'completed' as const : + fromSmallestUnit(o.currentAmount) > 0 ? 'in-progress' as const : 'not-started' as const, + }, + })) + + setNodes([...funnelNodes, ...outcomeNodes]) + setMode('live') + setIsSimulating(false) + showStatus(`Loaded flow "${flow.name}" (${flow.funnels.length} funnels, ${flow.outcomes.length} outcomes)`, 'success') + } catch (err) { + showStatus(`Failed to load flow: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error') + } + }, [safeAddress, showStatus]) + + // Auto-refresh in live mode + useEffect(() => { + if (mode !== 'live' || !deployedFlow) return + + const interval = setInterval(async () => { + try { + const latest = await getFlow(deployedFlow.id) + setDeployedFlow(latest) + setNodes(prev => syncNodesToBackend(prev, latest, Object.fromEntries( + prev.map(n => [n.id, n.id]) + ))) + } catch { + // Silent refresh failure + } + }, 30000) + + return () => clearInterval(interval) + }, [mode, deployedFlow]) + + const handleSwitchToDemo = useCallback(() => { + setNodes(demoNodes) + setMode('demo') + setIsSimulating(true) + setDeployedFlow(null) + setConnectedChains([]) + }, []) + + return ( +
+ {/* Status Toast */} + {statusMessage && ( +
+ {statusMessage.text} +
+ )} + + {/* Banner */} +
+
+ +
+ rF +
+ rFunds + + | + Budget River + {mode === 'live' && connectedChains.length > 0 && ( + <> + | + + {connectedChains.join(' + ')} + + + )} + {deployedFlow && ( + <> + | + + {deployedFlow.status} · {deployedFlow.name} + + + )} +
+ +
+ + Canvas View + + + Editor + + +
+ + {mode === 'live' ? ( + + ) : ( + + )} + + +
+
+ + {/* River Canvas */} +
+ +
+ + {/* Connect Safe Dialog */} + {showConnect && ( +
setShowConnect(false)}> +
e.stopPropagation()}> +

Connect Safe Treasury

+

+ Enter a Safe address to load real token balances from Gnosis and Optimism chains. +

+ setSafeAddress(e.target.value)} + placeholder="Safe address (0x...)" + className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm text-white font-mono text-xs mb-3 focus:border-cyan-500 focus:outline-none" + autoFocus + onKeyDown={e => e.key === 'Enter' && handleConnect()} + /> +
+ + +
+ +
+
+ )} + + {/* Legend */} +
+
Legend
+
+
+
+ Source / Inflow +
+
+
+ River (healthy) +
+
+
+ Overflow branch +
+
+
+ Spending outflow +
+
+
+ Outcome pool +
+
+
+
+ ) +} diff --git a/components/BudgetRiver.tsx b/components/BudgetRiver.tsx new file mode 100644 index 0000000..b8d8fb0 --- /dev/null +++ b/components/BudgetRiver.tsx @@ -0,0 +1,970 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback } from 'react' +import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from '@/lib/types' + +// ─── Layout Types ──────────────────────────────────────── + +interface RiverLayout { + sources: SourceLayout[] + funnels: FunnelLayout[] + outcomes: OutcomeLayout[] + sourceWaterfalls: WaterfallLayout[] + overflowBranches: BranchLayout[] + spendingWaterfalls: WaterfallLayout[] + width: number + height: number +} + +interface SourceLayout { + id: string + label: string + flowRate: number + x: number + y: number + width: number +} + +interface FunnelLayout { + id: string + label: string + data: FunnelNodeData + x: number + y: number + riverWidth: number + segmentLength: number + layer: number + status: 'healthy' | 'overflow' | 'critical' +} + +interface OutcomeLayout { + id: string + label: string + data: OutcomeNodeData + x: number + y: number + poolWidth: number + fillPercent: number +} + +interface WaterfallLayout { + id: string + sourceId: string + targetId: string + label: string + percentage: number + x: number + yStart: number + yEnd: number + width: number + color: string + flowAmount: number +} + +interface BranchLayout { + sourceId: string + targetId: string + percentage: number + x1: number + y1: number + x2: number + y2: number + width: number + color: string +} + +// ─── Constants ─────────────────────────────────────────── + +const LAYER_HEIGHT = 160 +const WATERFALL_HEIGHT = 100 +const GAP = 40 +const MIN_RIVER_WIDTH = 24 +const MAX_RIVER_WIDTH = 100 +const SEGMENT_LENGTH = 200 +const POOL_WIDTH = 100 +const POOL_HEIGHT = 60 +const SOURCE_HEIGHT = 40 + +const COLORS = { + sourceWaterfall: '#10b981', + riverHealthy: ['#0ea5e9', '#06b6d4'], + riverOverflow: ['#f59e0b', '#fbbf24'], + riverCritical: ['#ef4444', '#f87171'], + overflowBranch: '#f59e0b', + spendingWaterfall: ['#8b5cf6', '#ec4899', '#06b6d4', '#3b82f6', '#10b981', '#6366f1'], + outcomePool: '#3b82f6', + bg: '#0f172a', + text: '#e2e8f0', + textMuted: '#94a3b8', +} + +// ─── Layout Engine ─────────────────────────────────────── + +function computeLayout(nodes: FlowNode[]): RiverLayout { + const funnelNodes = nodes.filter(n => n.type === 'funnel') + const outcomeNodes = nodes.filter(n => n.type === 'outcome') + const sourceNodes = nodes.filter(n => n.type === 'source') + + // Build adjacency: which funnels are children (overflow targets) of which + const overflowTargets = new Set() + const spendingTargets = new Set() + const sourceTargets = new Set() + + funnelNodes.forEach(n => { + const data = n.data as FunnelNodeData + data.overflowAllocations?.forEach(a => overflowTargets.add(a.targetId)) + data.spendingAllocations?.forEach(a => spendingTargets.add(a.targetId)) + }) + sourceNodes.forEach(n => { + const data = n.data as SourceNodeData + data.targetAllocations?.forEach(a => sourceTargets.add(a.targetId)) + }) + + // Root funnels = funnels that are NOT overflow targets of other funnels + // (but may be source targets) + const rootFunnels = funnelNodes.filter(n => !overflowTargets.has(n.id)) + const childFunnels = funnelNodes.filter(n => overflowTargets.has(n.id)) + + // Assign layers + const funnelLayers = new Map() + rootFunnels.forEach(n => funnelLayers.set(n.id, 0)) + + // BFS to assign layers to overflow children + const queue = [...rootFunnels] + while (queue.length > 0) { + const current = queue.shift()! + const data = current.data as FunnelNodeData + const parentLayer = funnelLayers.get(current.id) ?? 0 + data.overflowAllocations?.forEach(a => { + const child = funnelNodes.find(n => n.id === a.targetId) + if (child && !funnelLayers.has(child.id)) { + funnelLayers.set(child.id, parentLayer + 1) + queue.push(child) + } + }) + } + + // Find max flow rate for normalization + const allFlowRates = funnelNodes.map(n => (n.data as FunnelNodeData).inflowRate || 1) + const maxFlowRate = Math.max(...allFlowRates, 1) + const maxValue = Math.max(...funnelNodes.map(n => (n.data as FunnelNodeData).currentValue || 1), 1) + + // Compute funnel layouts + // Group by layer, center each layer + const layerGroups = new Map() + funnelNodes.forEach(n => { + const layer = funnelLayers.get(n.id) ?? 0 + if (!layerGroups.has(layer)) layerGroups.set(layer, []) + layerGroups.get(layer)!.push(n) + }) + + const maxLayer = Math.max(...Array.from(layerGroups.keys()), 0) + const sourceLayerY = GAP + const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP + + const funnelLayouts: FunnelLayout[] = [] + const layerXRanges = new Map() + + for (let layer = 0; layer <= maxLayer; layer++) { + const layerNodes = layerGroups.get(layer) || [] + const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + const totalWidth = layerNodes.length * SEGMENT_LENGTH + (layerNodes.length - 1) * GAP * 2 + + layerNodes.forEach((n, i) => { + const data = n.data as FunnelNodeData + const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1)) + const riverWidth = MIN_RIVER_WIDTH + fillRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH) + const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2) + const status: 'healthy' | 'overflow' | 'critical' = + data.currentValue > data.maxThreshold ? 'overflow' : + data.currentValue < data.minThreshold ? 'critical' : 'healthy' + + funnelLayouts.push({ + id: n.id, + label: data.label, + data, + x, + y: layerY, + riverWidth, + segmentLength: SEGMENT_LENGTH, + layer, + status, + }) + + const range = layerXRanges.get(layer) || { minX: Infinity, maxX: -Infinity } + range.minX = Math.min(range.minX, x) + range.maxX = Math.max(range.maxX, x + SEGMENT_LENGTH) + layerXRanges.set(layer, range) + }) + } + + // Source layouts + const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => { + const data = n.data as SourceNodeData + const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP + return { + id: n.id, + label: data.label, + flowRate: data.flowRate, + x: -totalWidth / 2 + i * (120 + GAP), + y: sourceLayerY, + width: 120, + } + }) + + // Source waterfalls (source → root funnel) + const sourceWaterfalls: WaterfallLayout[] = [] + sourceNodes.forEach(sn => { + const data = sn.data as SourceNodeData + const sourceLayout = sourceLayouts.find(s => s.id === sn.id) + if (!sourceLayout) return + + data.targetAllocations?.forEach((alloc, i) => { + const targetLayout = funnelLayouts.find(f => f.id === alloc.targetId) + if (!targetLayout) return + + const flowAmount = (alloc.percentage / 100) * data.flowRate + const width = Math.max(6, (flowAmount / Math.max(data.flowRate, 1)) * 30) + + sourceWaterfalls.push({ + id: `src-wf-${sn.id}-${alloc.targetId}`, + sourceId: sn.id, + targetId: alloc.targetId, + label: `${alloc.percentage}%`, + percentage: alloc.percentage, + x: sourceLayout.x + sourceLayout.width / 2 + (i - (data.targetAllocations.length - 1) / 2) * 30, + yStart: sourceLayout.y + SOURCE_HEIGHT, + yEnd: targetLayout.y, + width, + color: COLORS.sourceWaterfall, + flowAmount, + }) + }) + }) + + // If no source nodes, create implicit waterfalls for root funnels with inflowRate + if (sourceNodes.length === 0) { + rootFunnels.forEach(rn => { + const data = rn.data as FunnelNodeData + if (data.inflowRate <= 0) return + const layout = funnelLayouts.find(f => f.id === rn.id) + if (!layout) return + + sourceWaterfalls.push({ + id: `implicit-wf-${rn.id}`, + sourceId: 'implicit', + targetId: rn.id, + label: `$${Math.floor(data.inflowRate)}/mo`, + percentage: 100, + x: layout.x + layout.segmentLength / 2, + yStart: GAP, + yEnd: layout.y, + width: Math.max(8, (data.inflowRate / maxFlowRate) * 30), + color: COLORS.sourceWaterfall, + flowAmount: data.inflowRate, + }) + }) + } + + // Overflow branches (funnel → child funnel) + const overflowBranches: BranchLayout[] = [] + funnelNodes.forEach(n => { + const data = n.data as FunnelNodeData + const parentLayout = funnelLayouts.find(f => f.id === n.id) + if (!parentLayout) return + + data.overflowAllocations?.forEach((alloc, i) => { + const childLayout = funnelLayouts.find(f => f.id === alloc.targetId) + if (!childLayout) return + + const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1) + const width = Math.max(4, (alloc.percentage / 100) * parentLayout.riverWidth * 0.6) + + overflowBranches.push({ + sourceId: n.id, + targetId: alloc.targetId, + percentage: alloc.percentage, + x1: parentLayout.x + parentLayout.segmentLength, + y1: parentLayout.y + parentLayout.riverWidth / 2, + x2: childLayout.x, + y2: childLayout.y + childLayout.riverWidth / 2, + width, + color: alloc.color || COLORS.overflowBranch, + }) + }) + }) + + // Outcome layouts + const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT + const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP + const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => { + const data = n.data as OutcomeNodeData + const fillPercent = data.fundingTarget > 0 + ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) + : 0 + + return { + id: n.id, + label: data.label, + data, + x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP), + y: outcomeY, + poolWidth: POOL_WIDTH, + fillPercent, + } + }) + + // Spending waterfalls (funnel → outcome) + const spendingWaterfalls: WaterfallLayout[] = [] + funnelNodes.forEach(n => { + const data = n.data as FunnelNodeData + const parentLayout = funnelLayouts.find(f => f.id === n.id) + if (!parentLayout) return + + data.spendingAllocations?.forEach((alloc, i) => { + const outcomeLayout = outcomeLayouts.find(o => o.id === alloc.targetId) + if (!outcomeLayout) return + + const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1) + const width = Math.max(4, (alloc.percentage / 100) * 24) + + spendingWaterfalls.push({ + id: `spend-wf-${n.id}-${alloc.targetId}`, + sourceId: n.id, + targetId: alloc.targetId, + label: `${alloc.percentage}%`, + percentage: alloc.percentage, + x: parentLayout.x + parentLayout.segmentLength / 2 + + (i - ((data.spendingAllocations?.length || 1) - 1) / 2) * 24, + yStart: parentLayout.y + parentLayout.riverWidth + 4, + yEnd: outcomeLayout.y, + width, + color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], + flowAmount, + }) + }) + }) + + // Compute total bounds + const allX = [ + ...funnelLayouts.map(f => f.x), + ...funnelLayouts.map(f => f.x + f.segmentLength), + ...outcomeLayouts.map(o => o.x), + ...outcomeLayouts.map(o => o.x + o.poolWidth), + ...sourceLayouts.map(s => s.x), + ...sourceLayouts.map(s => s.x + s.width), + ] + const allY = [ + ...funnelLayouts.map(f => f.y + f.riverWidth), + ...outcomeLayouts.map(o => o.y + POOL_HEIGHT), + sourceLayerY, + ] + + const minX = Math.min(...allX, -100) + const maxX = Math.max(...allX, 100) + const maxY = Math.max(...allY, 400) + const padding = 80 + + // Shift everything so minX starts at padding + const offsetX = -minX + padding + const offsetY = padding + + // Apply offsets + funnelLayouts.forEach(f => { f.x += offsetX; f.y += offsetY }) + outcomeLayouts.forEach(o => { o.x += offsetX; o.y += offsetY }) + sourceLayouts.forEach(s => { s.x += offsetX; s.y += offsetY }) + sourceWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY }) + overflowBranches.forEach(b => { b.x1 += offsetX; b.y1 += offsetY; b.x2 += offsetX; b.y2 += offsetY }) + spendingWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY }) + + return { + sources: sourceLayouts, + funnels: funnelLayouts, + outcomes: outcomeLayouts, + sourceWaterfalls, + overflowBranches, + spendingWaterfalls, + width: maxX - minX + padding * 2, + height: maxY + offsetY + padding, + } +} + +// ─── SVG Sub-components ────────────────────────────────── + +function WaterfallStream({ wf, index }: { wf: WaterfallLayout; index: number }) { + const height = wf.yEnd - wf.yStart + const numDroplets = Math.max(3, Math.min(8, Math.floor(wf.width / 3))) + + return ( + + {/* Main water stream */} + + + + + + + + + + + + {/* Glow behind */} + + + {/* Animated water strips */} + + {[0, 1, 2].map(i => ( + + ))} + + + {/* Side mist lines */} + + + + {/* Droplets */} + {Array.from({ length: numDroplets }, (_, i) => { + const dx = (Math.random() - 0.5) * (wf.width + 10) + const delay = Math.random() * 2 + const dur = 1 + Math.random() * 1.5 + return ( + + ) + })} + + {/* Splash ripples at bottom */} + {[0, 1, 2].map(i => ( + + ))} + + {/* Label */} + + {wf.label} + + + ) +} + +function RiverSegment({ funnel }: { funnel: FunnelLayout }) { + const { id, label, data, x, y, riverWidth, segmentLength, status } = funnel + const gradColors = status === 'overflow' ? COLORS.riverOverflow : + status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy + + const fillPercent = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100) + const thresholdMinY = y + riverWidth * 0.85 + const thresholdMaxY = y + riverWidth * 0.15 + + return ( + + + + + + + + + + + {/* River glow */} + + + {/* River body */} + + + {/* Surface current lines */} + {[0.3, 0.5, 0.7].map((pos, i) => ( + + ))} + + {/* Shimmer highlight */} + + + {/* Threshold markers */} + + MAX + + + MIN + + {/* Label */} + + {label} + + + {/* Value */} + + ${Math.floor(data.currentValue).toLocaleString()} + + + {/* Status pill */} + + + {status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'} + + + ) +} + +function OverflowBranch({ branch }: { branch: BranchLayout }) { + const { x1, y1, x2, y2, width, color, percentage } = branch + const midX = (x1 + x2) / 2 + const midY = (y1 + y2) / 2 + + // Curved path connecting two river segments + const path = `M ${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}` + + return ( + + {/* Glow */} + + {/* Main branch */} + + {/* Flow direction arrows */} + + {percentage}% + + + ) +} + +function OutcomePool({ outcome }: { outcome: OutcomeLayout }) { + const { id, label, data, x, y, poolWidth, fillPercent } = outcome + const fillHeight = (fillPercent / 100) * POOL_HEIGHT + + const statusColor = + data.status === 'completed' ? '#10b981' : + data.status === 'in-progress' ? '#3b82f6' : + data.status === 'blocked' ? '#ef4444' : '#64748b' + + return ( + + {/* Pool container */} + + + {/* Water fill */} + + + + + + + + + + + + + {/* Water surface wave */} + {fillPercent > 5 && ( + + )} + + + {/* Label */} + + {label} + + + {/* Value */} + + ${Math.floor(data.fundingReceived).toLocaleString()} + + + {/* Progress */} + + {Math.round(fillPercent)}% of ${Math.floor(data.fundingTarget).toLocaleString()} + + + ) +} + +function SourceBox({ source }: { source: SourceLayout }) { + return ( + + + + {source.label} + + + ${source.flowRate.toLocaleString()}/mo + + + ) +} + +// ─── Main Component ────────────────────────────────────── + +interface BudgetRiverProps { + nodes: FlowNode[] + isSimulating?: boolean +} + +export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiverProps) { + const [animatedNodes, setAnimatedNodes] = useState(nodes) + + // Update when parent nodes change + useEffect(() => { + setAnimatedNodes(nodes) + }, [nodes]) + + // Simulation + useEffect(() => { + if (!isSimulating) return + + const interval = setInterval(() => { + setAnimatedNodes(prev => + prev.map(node => { + 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() * 80 + const newReceived = Math.min(data.fundingTarget * 1.05, 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) + + return () => clearInterval(interval) + }, [isSimulating]) + + const layout = useMemo(() => computeLayout(animatedNodes), [animatedNodes]) + + return ( +
+ + {/* Background */} + + + {/* Background stars/dots for atmosphere */} + {Array.from({ length: 30 }, (_, i) => ( + + ))} + + {/* Layer 1: Source boxes */} + {layout.sources.map(s => ( + + ))} + + {/* Layer 2: Source waterfalls (flowing into river) */} + {layout.sourceWaterfalls.map((wf, i) => ( + + ))} + + {/* Layer 3: River segments */} + {layout.funnels.map(f => ( + + ))} + + {/* Layer 4: Overflow branches */} + {layout.overflowBranches.map((b, i) => ( + + ))} + + {/* Layer 5: Spending waterfalls (flowing out) */} + {layout.spendingWaterfalls.map((wf, i) => ( + + ))} + + {/* Layer 6: Outcome pools */} + {layout.outcomes.map(o => ( + + ))} + + {/* Title watermark */} + + rFUNDS BUDGET RIVER + + +
+ ) +} diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index 128addc..b7ce92c 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -18,16 +18,17 @@ 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 type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types' -import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets' -import { simulateTick } from '@/lib/simulation' +import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, SourceNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types' +import { SPENDING_COLORS, OVERFLOW_COLORS, SOURCE_COLORS } from '@/lib/presets' const nodeTypes = { funnel: FunnelNode, outcome: OutcomeNode, + source: SourceNode, } const edgeTypes = { @@ -144,7 +145,7 @@ function generateEdges( }) }) - // Stream edges (Superfluid visual planning) + // Stream edges (Superfluid planning) data.streamAllocations?.forEach((stream) => { const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e' edges.push({ @@ -175,6 +176,48 @@ function generateEdges( }) }) + // 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 || '#10b981', + strokeWidth, + opacity: 0.8, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: alloc.color || '#10b981', + width: 16, + height: 16, + }, + data: { + allocation: alloc.percentage, + color: alloc.color || '#10b981', + edgeType: 'spending' as const, + sourceId: node.id, + targetId: alloc.targetId, + siblingCount: allocCount, + onAdjust, + }, + type: 'allocation', + }) + }) + }) + return edges } @@ -191,6 +234,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes) const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[]) const [isSimulating, setIsSimulating] = useState(mode === 'demo') + const [panelsCollapsed, setPanelsCollapsed] = useState(false) const edgesRef = useRef(edges) edgesRef.current = edges const { screenToFlowPosition } = useReactFlow() @@ -254,20 +298,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 funnelKeys = 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 sourceKeys = nodes + .filter(n => n.type === 'source') + .map(n => { + const d = n.data as SourceNodeData + return { + id: n.id, + targets: d.targetAllocations, + rate: d.flowRate, + } + }) + return JSON.stringify({ funnelKeys, sourceKeys }) }, [nodes]) useEffect(() => { @@ -282,8 +335,39 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra const isOverflow = params.sourceHandle?.startsWith('outflow') const isSpending = params.sourceHandle === 'spending-out' const isStream = params.sourceHandle === 'stream-out' + const isSourceOut = params.sourceHandle === 'source-out' - if (!isOverflow && !isSpending && !isStream) return + if (!isOverflow && !isSpending && !isStream && !isSourceOut) return + + // Handle 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 @@ -369,6 +453,24 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra const newTargetId = newConnection.target if (oldTargetId === newTargetId) return + // Source edges: reconnect source allocation + if (oldEdge.id?.startsWith('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) => { @@ -423,6 +525,27 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra if (change.type === 'remove') { const edge = edgesRef.current.find(e => e.id === change.id) if (edge?.data) { + // Source edge removal + if (edge.id?.startsWith('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, + }, + } + })) + onEdgesChange([change]) + return + } + setNodes((nds) => nds.map((node) => { if (node.id !== edge.source || node.type !== 'funnel') return node const data = node.data as FunnelNodeData @@ -524,17 +647,129 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra ]) }, [setNodes, screenToFlowPosition]) - // Simulation — real flow logic (inflow → overflow → spending → outcomes) + // 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', + flowRate: 500, + sourceType: 'recurring', + targetAllocations: [], + } as SourceNodeData, + }, + ]) + }, [setNodes, screenToFlowPosition]) + + // Handle node deletion — clean up references in other nodes + const onNodesDelete = useCallback((deletedNodes: FlowNode[]) => { + const deletedIds = new Set(deletedNodes.map(n => n.id)) + + setNodes((nds) => nds.map((node) => { + if (node.type === 'source') { + const data = node.data as SourceNodeData + const filtered = (data.targetAllocations || []).filter(a => !deletedIds.has(a.targetId)) + if (filtered.length === data.targetAllocations?.length) return node + 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, + }, + } + } + if (node.type === 'funnel') { + const data = node.data as FunnelNodeData + let changed = false + + let overflow = data.overflowAllocations || [] + const filteredOverflow = overflow.filter(a => !deletedIds.has(a.targetId)) + if (filteredOverflow.length !== overflow.length) { + changed = true + const total = filteredOverflow.reduce((s, a) => s + a.percentage, 0) + overflow = total > 0 + ? filteredOverflow.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) + : filteredOverflow + } + + let spending = data.spendingAllocations || [] + const filteredSpending = spending.filter(a => !deletedIds.has(a.targetId)) + if (filteredSpending.length !== spending.length) { + changed = true + const total = filteredSpending.reduce((s, a) => s + a.percentage, 0) + spending = total > 0 + ? filteredSpending.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) })) + : filteredSpending + } + + if (!changed) return node + return { + ...node, + data: { + ...data, + overflowAllocations: overflow, + spendingAllocations: spending, + }, + } + } + return node + })) + }, [setNodes]) + + // Simulation useEffect(() => { if (!isSimulating) return const interval = setInterval(() => { - setNodes((nds) => simulateTick(nds as FlowNode[])) - }, 1000) + setNodes((nds) => + nds.map((node) => { + 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() * 80 + const newReceived = Math.min(data.fundingTarget * 1.05, 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) return () => clearInterval(interval) }, [isSimulating, setNodes]) + // Auto-collapse title & legend panels after 5 seconds + useEffect(() => { + const timer = setTimeout(() => setPanelsCollapsed(true), 5000) + return () => clearTimeout(timer) + }, []) + return (
{/* Title Panel */} - -

Threshold-Based Flow Funding

-

- Inflows (top) • - Overflow (sides) • - Spending (bottom) -

-

- Drag handles to connect • Double-click funnels to edit • Select + Delete to remove edges -

+ +
setPanelsCollapsed((c) => !c)} + > +
+

TBFF

+ + + +
+ {!panelsCollapsed && ( +
+

+ Sources → + Funnels → + Outcomes +

+

+ Drag handles to connect • Double-click to edit • Select + Delete to remove +

+
+ )} +
{/* Top-right Controls */} {mode === 'space' && ( - <> - - - - + )} + + + +
+ +
+
+ + setEditLabel(e.target.value)} + className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800" + autoFocus + /> +
+ +
+ + setEditRate(e.target.value)} + className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800 font-mono" + min="0" + /> +
+ +
+ + +
+
+ +
+ +
+
+ + )} + + ) +} + +export default memo(SourceNode) diff --git a/lib/presets.ts b/lib/presets.ts index 2e487df..a010203 100644 --- a/lib/presets.ts +++ b/lib/presets.ts @@ -1,12 +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[] = [ - // Main Treasury Funnel (top center) + // Revenue source (top) + { + id: 'revenue', + type: 'source', + position: { x: 660, y: -200 }, + data: { + label: 'Revenue Stream', + flowRate: 5000, + sourceType: 'recurring', + targetAllocations: [ + { targetId: 'treasury', percentage: 100, color: '#10b981' }, + ], + } as SourceNodeData, + }, + // Main Treasury Funnel { id: 'treasury', type: 'funnel', diff --git a/lib/types.ts b/lib/types.ts index 5d8da53..2d5843b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -83,6 +83,22 @@ export interface FundingSource { lastUsedAt?: number; } +// ─── Source Node Types ─────────────────────────────────────── + +export interface SourceAllocation { + targetId: string + percentage: number // 0-100 + color: string +} + +export interface SourceNodeData { + label: string + flowRate: number // tokens per month flowing out + sourceType: 'recurring' | 'one-time' | 'treasury' + targetAllocations: SourceAllocation[] + [key: string]: unknown +} + // ─── Core Flow Types ───────────────────────────────────────── // Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold @@ -129,7 +145,7 @@ export interface OutcomeNodeData { [key: string]: unknown } -export type FlowNode = Node +export type FlowNode = Node export interface AllocationEdgeData { allocation: number // percentage 0-100