From cff46bb0fcffcd80b5ac5f8c363d2b0291533348 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 09:43:13 +0000 Subject: [PATCH 1/5] feat: standardize ecosystem footer with all 16 r-suite apps Co-Authored-By: Claude Opus 4.6 --- app/page.tsx | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index fad6dd6..1cf9b88 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ import Link from 'next/link' +import { AuthButton } from '@/components/AuthButton' export default function Home() { return ( @@ -25,6 +26,7 @@ export default function Home() { > Create Space + @@ -165,16 +167,22 @@ export default function Home() {
r* Ecosystem - 🌌 rSpace - πŸ—ΊοΈ rMaps - πŸ“ rNotes - πŸ—³οΈ rVote - πŸ’° rFunds - ✈️ rTrips - πŸ›’ rCart - πŸ’Ό rWallet - πŸ“ rFiles - 🌐 rNetwork + rSpace + rMaps + rNotes + rVote + rFunds + rTrips + rCart + rWallet + rFiles + rTube + rCal + rNetwork + rInbox + rStack + rAuctions + rPubs

Part of the r* ecosystem β€” collaborative tools for communities. From e683175c655ec9870e1795c1e42af8936870a693 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 10:02:49 +0000 Subject: [PATCH 2/5] feat: add Budget River waterfall visualization view New /river page showing funding flows as an animated river with waterfalls cascading in (inflows) and out (spending). SVG-based with CSS animations for water effects, ripples, and current lines. Supports demo mode with simulated data and live mode via Safe Global API for real on-chain balances. Also includes source node type and landing page nav links. Co-Authored-By: Claude Opus 4.6 --- app/globals.css | 40 ++ app/page.tsx | 12 + app/river/page.tsx | 350 ++++++++++++ components/BudgetRiver.tsx | 970 ++++++++++++++++++++++++++++++++ components/FlowCanvas.tsx | 432 +++++++++++--- components/nodes/SourceNode.tsx | 208 +++++++ lib/presets.ts | 21 +- lib/types.ts | 18 +- 8 files changed, 1974 insertions(+), 77 deletions(-) create mode 100644 app/river/page.tsx create mode 100644 components/BudgetRiver.tsx create mode 100644 components/nodes/SourceNode.tsx 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 From 0afb85e9f7a05509d42c47c6e682b84192f45efb Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 10:32:07 +0000 Subject: [PATCH 3/5] feat: sankey-proportional waterfalls + multi-chain Safe support Rewrite budget river waterfalls with bezier-curved tapered shapes where width encodes flow magnitude (inflows flare into river, outflows taper out). Add Ethereum, Base, Polygon, Arbitrum to Safe chain detection. Fetch real transaction history for live inflow rates. Co-Authored-By: Claude Opus 4.6 --- app/globals.css | 5 + app/river/page.tsx | 14 +- components/BudgetRiver.tsx | 371 +++++++++++++++++++++----------- components/IntegrationPanel.tsx | 2 +- lib/api/safe-client.ts | 140 +++++++++++- lib/integrations.ts | 18 +- 6 files changed, 415 insertions(+), 135 deletions(-) diff --git a/app/globals.css b/app/globals.css index d901bdf..b986fed 100644 --- a/app/globals.css +++ b/app/globals.css @@ -65,3 +65,8 @@ body { 50% { opacity: 0.7; } 100% { opacity: 0.3; } } + +@keyframes mergeSplash { + 0% { rx: 2; ry: 1; opacity: 0.5; } + 100% { rx: 20; ry: 4; opacity: 0; } +} diff --git a/app/river/page.tsx b/app/river/page.tsx index f264d68..af0d05c 100644 --- a/app/river/page.tsx +++ b/app/river/page.tsx @@ -4,7 +4,7 @@ import dynamic from 'next/dynamic' import Link from 'next/link' import { useState, useCallback, useEffect } from 'react' import { demoNodes } from '@/lib/presets' -import { detectSafeChains, getBalances } from '@/lib/api/safe-client' +import { detectSafeChains, getBalances, computeTransferSummary } from '@/lib/api/safe-client' import { safeBalancesToFunnels } from '@/lib/integrations' import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client' import type { FlowNode, FunnelNodeData } from '@/lib/types' @@ -52,7 +52,7 @@ export default function RiverPage() { 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') + showStatus('No Safe found on supported chains (Ethereum, Base, Polygon, Arbitrum, Optimism, Gnosis)', 'error') setConnecting(false) return } @@ -60,14 +60,18 @@ export default function RiverPage() { const chainNames = detected.map(d => d.chain.name) setConnectedChains(chainNames) - // Fetch balances from all detected chains and create funnel nodes + // Fetch balances + transfer history from all detected chains const allFunnelNodes: FlowNode[] = [] for (const chain of detected) { - const balances = await getBalances(safeAddress.trim(), chain.chainId) + const [balances, transferSummary] = await Promise.all([ + getBalances(safeAddress.trim(), chain.chainId), + computeTransferSummary(safeAddress.trim(), chain.chainId), + ]) const funnels = safeBalancesToFunnels( balances, safeAddress.trim(), chain.chainId, + transferSummary, { x: allFunnelNodes.length * 280, y: 100 } ) allFunnelNodes.push(...funnels) @@ -282,7 +286,7 @@ export default function RiverPage() {
e.stopPropagation()}>

Connect Safe Treasury

- Enter a Safe address to load real token balances from Gnosis and Optimism chains. + Enter a Safe address to load real token balances across Ethereum, Base, Polygon, Arbitrum, Optimism, and Gnosis.

s + p, 0) + if (totalPct === 0) return percentages.map(() => minWidth) + + let widths = percentages.map(p => (p / totalPct) * totalAvailable) + + // Enforce minimums: bump small ones up, proportionally reduce large ones + const belowMin = widths.filter(w => w < minWidth) + if (belowMin.length > 0 && belowMin.length < widths.length) { + const deficit = belowMin.reduce((s, w) => s + (minWidth - w), 0) + const aboveMinTotal = widths.filter(w => w >= minWidth).reduce((s, w) => s + w, 0) + + widths = widths.map(w => { + if (w < minWidth) return minWidth + return Math.max(minWidth, w - (w / aboveMinTotal) * deficit) + }) + } + + return widths +} + // ─── Layout Engine ─────────────────────────────────────── function computeLayout(nodes: FlowNode[]): RiverLayout { @@ -121,15 +154,12 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { }) // 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 + // Assign layers via BFS 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()! @@ -144,13 +174,11 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { }) } - // Find max flow rate for normalization + // Find max flow rate for implicit waterfall 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 + // Group funnels by layer, center each layer const layerGroups = new Map() funnelNodes.forEach(n => { const layer = funnelLayers.get(n.id) ?? 0 @@ -163,7 +191,6 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { 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) || [] @@ -190,11 +217,6 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { 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) }) } @@ -212,19 +234,56 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { } }) - // Source waterfalls (source β†’ root funnel) + // ─── Source waterfalls (sankey-proportional inflows) ────── + + // First pass: collect all inflows per funnel to compute shares + const inflowsByFunnel = new Map() + + sourceNodes.forEach(sn => { + const data = sn.data as SourceNodeData + data.targetAllocations?.forEach((alloc, i) => { + const flowAmount = (alloc.percentage / 100) * data.flowRate + if (!inflowsByFunnel.has(alloc.targetId)) inflowsByFunnel.set(alloc.targetId, []) + inflowsByFunnel.get(alloc.targetId)!.push({ sourceNodeId: sn.id, allocIndex: i, flowAmount, percentage: alloc.percentage }) + }) + }) + 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) => { + data.targetAllocations?.forEach((alloc, allocIdx) => { 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) + const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || [] + const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0) + + // Sankey width: proportional share of the target river width + const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1 + const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth) + + // Far end width: proportional share of source box width + const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8) + + // Position along river top edge: distribute side by side + const myIndex = allInflowsToTarget.findIndex(i => i.sourceNodeId === sn.id && i.allocIndex === allocIdx) + const inflowWidths = distributeWidths( + allInflowsToTarget.map(i => i.flowAmount), + targetLayout.segmentLength * 0.7, + MIN_WATERFALL_WIDTH + ) + const startX = targetLayout.x + targetLayout.segmentLength * 0.15 + let offsetX = 0 + for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k] + const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2 + + // Source center x + const sourceCenterX = sourceLayout.x + sourceLayout.width / 2 sourceWaterfalls.push({ id: `src-wf-${sn.id}-${alloc.targetId}`, @@ -232,17 +291,21 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, - x: sourceLayout.x + sourceLayout.width / 2 + (i - (data.targetAllocations.length - 1) / 2) * 30, + x: riverCenterX, + xSource: sourceCenterX, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, - width, + width: riverEndWidth, + riverEndWidth, + farEndWidth, + direction: 'inflow', color: COLORS.sourceWaterfall, flowAmount, }) }) }) - // If no source nodes, create implicit waterfalls for root funnels with inflowRate + // Implicit waterfalls for root funnels with inflowRate but no source nodes if (sourceNodes.length === 0) { rootFunnels.forEach(rn => { const data = rn.data as FunnelNodeData @@ -250,6 +313,9 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { const layout = funnelLayouts.find(f => f.id === rn.id) if (!layout) return + const riverEndWidth = layout.riverWidth + const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4) + sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: 'implicit', @@ -257,28 +323,32 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: layout.x + layout.segmentLength / 2, + xSource: layout.x + layout.segmentLength / 2, yStart: GAP, yEnd: layout.y, - width: Math.max(8, (data.inflowRate / maxFlowRate) * 30), + width: riverEndWidth, + riverEndWidth, + farEndWidth, + direction: 'inflow', color: COLORS.sourceWaterfall, flowAmount: data.inflowRate, }) }) } - // Overflow branches (funnel β†’ child funnel) + // ─── Overflow branches (sankey-proportional) ────── + 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) => { + data.overflowAllocations?.forEach((alloc) => { 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) + const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth) overflowBranches.push({ sourceId: n.id, @@ -294,7 +364,8 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { }) }) - // Outcome layouts + // ─── 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) => { @@ -314,19 +385,43 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { } }) - // Spending waterfalls (funnel β†’ outcome) + // ─── Spending waterfalls (sankey-proportional outflows) ────── + 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 allocations = data.spendingAllocations || [] + if (allocations.length === 0) return + + // Distribute outflows along the river bottom edge + const percentages = allocations.map(a => a.percentage) + const slotWidths = distributeWidths( + percentages, + parentLayout.segmentLength * 0.7, + MIN_WATERFALL_WIDTH + ) + const riverEndWidths = distributeWidths( + percentages, + parentLayout.riverWidth, + MIN_WATERFALL_WIDTH + ) + const startX = parentLayout.x + parentLayout.segmentLength * 0.15 + + let offsetX = 0 + allocations.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) + const riverEndWidth = riverEndWidths[i] + const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6) + + const riverCenterX = startX + offsetX + slotWidths[i] / 2 + offsetX += slotWidths[i] + + const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2 spendingWaterfalls.push({ id: `spend-wf-${n.id}-${alloc.targetId}`, @@ -334,13 +429,16 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, - x: parentLayout.x + parentLayout.segmentLength / 2 + - (i - ((data.spendingAllocations?.length || 1) - 1) / 2) * 24, + x: riverCenterX, + xSource: poolCenterX, yStart: parentLayout.y + parentLayout.riverWidth + 4, yEnd: outcomeLayout.y, - width, + width: riverEndWidth, + riverEndWidth, + farEndWidth, + direction: 'outflow', color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], - flowAmount, + flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1), }) }) }) @@ -366,16 +464,16 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { const padding = 80 // Shift everything so minX starts at padding - const offsetX = -minX + padding - const offsetY = padding + const offsetXGlobal = -minX + padding + const offsetYGlobal = 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 }) + funnelLayouts.forEach(f => { f.x += offsetXGlobal; f.y += offsetYGlobal }) + outcomeLayouts.forEach(o => { o.x += offsetXGlobal; o.y += offsetYGlobal }) + sourceLayouts.forEach(s => { s.x += offsetXGlobal; s.y += offsetYGlobal }) + sourceWaterfalls.forEach(w => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal }) + overflowBranches.forEach(b => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal }) + spendingWaterfalls.forEach(w => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal }) return { sources: sourceLayouts, @@ -385,125 +483,157 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, - height: maxY + offsetY + padding, + height: maxY + offsetYGlobal + padding, } } // ─── SVG Sub-components ────────────────────────────────── -function WaterfallStream({ wf, index }: { wf: WaterfallLayout; index: number }) { +function SankeyWaterfall({ wf }: { wf: WaterfallLayout }) { + const isInflow = wf.direction === 'inflow' const height = wf.yEnd - wf.yStart - const numDroplets = Math.max(3, Math.min(8, Math.floor(wf.width / 3))) + if (height <= 0) return null + + // For inflows: narrow at top (source), wide at bottom (river) + // For outflows: wide at top (river), narrow at bottom (pool) + const topWidth = isInflow ? wf.farEndWidth : wf.riverEndWidth + const bottomWidth = isInflow ? wf.riverEndWidth : wf.farEndWidth + const topCx = isInflow ? wf.xSource : wf.x + const bottomCx = isInflow ? wf.x : wf.xSource + + // Bezier control points for the taper curve + // Inflow: narrow for 55%, then flares in last 45% + // Outflow: wide for first 20%, then tapers + const cpFrac1 = isInflow ? 0.55 : 0.2 + const cpFrac2 = isInflow ? 0.75 : 0.45 + const cpY1 = wf.yStart + height * cpFrac1 + const cpY2 = wf.yStart + height * cpFrac2 + + const tl = topCx - topWidth / 2 + const tr = topCx + topWidth / 2 + const bl = bottomCx - bottomWidth / 2 + const br = bottomCx + bottomWidth / 2 + + const shapePath = [ + `M ${tl} ${wf.yStart}`, + `C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}`, + `L ${br} ${wf.yEnd}`, + `C ${br} ${cpY2}, ${tr} ${cpY1}, ${tr} ${wf.yStart}`, + `Z` + ].join(' ') + + const clipId = `sankey-clip-${wf.id}` + const gradId = `sankey-grad-${wf.id}` + + // Ripple at the wide (river) end + const rippleCx = isInflow ? bottomCx : topCx + const rippleCy = isInflow ? wf.yEnd : wf.yStart + + // Bounding box for animated strips + const pathMinX = Math.min(tl, bl) - 5 + const pathMaxW = Math.max(topWidth, bottomWidth) + 10 return ( - {/* Main water stream */} - - - - - - - + + + + + + + - {/* Glow behind */} - + {/* Glow behind shape */} + - {/* Animated water strips */} - + {/* Main filled shape */} + + + {/* Animated water strips inside the shape */} + {[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 */} + {/* Merge ripples at river junction */} {[0, 1, 2].map(i => ( - ))} + {/* Droplets at narrow end */} + {Array.from({ length: Math.max(2, Math.min(5, Math.floor(wf.riverEndWidth / 8))) }, (_, i) => { + const narrowCx = isInflow ? topCx : bottomCx + const narrowCy = isInflow ? wf.yStart : wf.yEnd + const narrowW = isInflow ? topWidth : bottomWidth + const dx = (Math.random() - 0.5) * narrowW + return ( + + ) + })} + {/* Label */} - {/* Flow direction arrows */} + {/* Flow direction label */} ))} - {/* Layer 2: Source waterfalls (flowing into river) */} - {layout.sourceWaterfalls.map((wf, i) => ( - + {/* Layer 2: Source waterfalls (sankey inflows pouring into river) */} + {layout.sourceWaterfalls.map(wf => ( + ))} {/* Layer 3: River segments */} @@ -942,9 +1071,9 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver ))} - {/* Layer 5: Spending waterfalls (flowing out) */} - {layout.spendingWaterfalls.map((wf, i) => ( - + {/* Layer 5: Spending waterfalls (sankey outflows pouring out) */} + {layout.spendingWaterfalls.map(wf => ( + ))} {/* Layer 6: Outcome pools */} diff --git a/components/IntegrationPanel.tsx b/components/IntegrationPanel.tsx index 370cdb3..2e97e9d 100644 --- a/components/IntegrationPanel.tsx +++ b/components/IntegrationPanel.tsx @@ -88,7 +88,7 @@ export default function IntegrationPanel({ let xOffset = 0 balances.forEach((chainBalances, chainId) => { - const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, { + const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, undefined, { x: xOffset, y: 100, }) diff --git a/lib/api/safe-client.ts b/lib/api/safe-client.ts index b541874..8ef6e46 100644 --- a/lib/api/safe-client.ts +++ b/lib/api/safe-client.ts @@ -13,6 +13,22 @@ export interface ChainConfig { } export const SUPPORTED_CHAINS: Record = { + 1: { + name: 'Ethereum', + slug: 'mainnet', + txService: 'https://safe-transaction-mainnet.safe.global', + explorer: 'https://etherscan.io', + color: '#627eea', + symbol: 'ETH', + }, + 10: { + name: 'Optimism', + slug: 'optimism', + txService: 'https://safe-transaction-optimism.safe.global', + explorer: 'https://optimistic.etherscan.io', + color: '#ff0420', + symbol: 'ETH', + }, 100: { name: 'Gnosis', slug: 'gnosis-chain', @@ -21,12 +37,28 @@ export const SUPPORTED_CHAINS: Record = { color: '#04795b', symbol: 'xDAI', }, - 10: { - name: 'Optimism', - slug: 'optimism', - txService: 'https://safe-transaction-optimism.safe.global', - explorer: 'https://optimistic.etherscan.io', - color: '#ff0420', + 137: { + name: 'Polygon', + slug: 'polygon', + txService: 'https://safe-transaction-polygon.safe.global', + explorer: 'https://polygonscan.com', + color: '#8247e5', + symbol: 'MATIC', + }, + 8453: { + name: 'Base', + slug: 'base', + txService: 'https://safe-transaction-base.safe.global', + explorer: 'https://basescan.org', + color: '#0052ff', + symbol: 'ETH', + }, + 42161: { + name: 'Arbitrum One', + slug: 'arbitrum', + txService: 'https://safe-transaction-arbitrum.safe.global', + explorer: 'https://arbiscan.io', + color: '#28a0f0', symbol: 'ETH', }, } @@ -148,3 +180,99 @@ export async function detectSafeChains( return results } + +// ─── Transfer History ────────────────────────────────────── + +export interface SafeTransfer { + type: 'ETHER_TRANSFER' | 'ERC20_TRANSFER' + executionDate: string + transactionHash: string + to: string + from: string + value: string + tokenAddress: string | null + tokenInfo?: { + name: string + symbol: string + decimals: number + } +} + +export interface TransferSummary { + chainId: number + totalInflow30d: number + totalOutflow30d: number + inflowRate: number + outflowRate: number + incomingTransfers: SafeTransfer[] + outgoingTransfers: SafeTransfer[] +} + +export async function getIncomingTransfers( + address: string, + chainId: number, + limit = 100 +): Promise { + const data = await fetchJSON<{ results: SafeTransfer[] }>( + apiUrl(chainId, `/safes/${address}/incoming-transfers/?limit=${limit}&executed=true`) + ) + return data?.results || [] +} + +export async function getOutgoingTransfers( + address: string, + chainId: number, + limit = 100 +): Promise { + const data = await fetchJSON<{ results: Array> }>( + apiUrl(chainId, `/safes/${address}/multisig-transactions/?limit=${limit}&executed=true`) + ) + if (!data?.results) return [] + + return data.results + .filter(tx => tx.value && parseInt(tx.value as string, 10) > 0) + .map(tx => ({ + type: (tx.dataDecoded ? 'ERC20_TRANSFER' : 'ETHER_TRANSFER') as SafeTransfer['type'], + executionDate: (tx.executionDate as string) || '', + transactionHash: (tx.transactionHash as string) || '', + to: (tx.to as string) || '', + from: address, + value: (tx.value as string) || '0', + tokenAddress: null, + tokenInfo: undefined, + })) +} + +export async function computeTransferSummary( + address: string, + chainId: number +): Promise { + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + + const [incoming, outgoing] = await Promise.all([ + getIncomingTransfers(address, chainId), + getOutgoingTransfers(address, chainId), + ]) + + const recentIncoming = incoming.filter(t => new Date(t.executionDate) >= thirtyDaysAgo) + const recentOutgoing = outgoing.filter(t => new Date(t.executionDate) >= thirtyDaysAgo) + + const sumTransfers = (transfers: SafeTransfer[]) => + transfers.reduce((sum, t) => { + const decimals = t.tokenInfo?.decimals ?? 18 + return sum + parseFloat(t.value) / Math.pow(10, decimals) + }, 0) + + const totalIn = sumTransfers(recentIncoming) + const totalOut = sumTransfers(recentOutgoing) + + return { + chainId, + totalInflow30d: totalIn, + totalOutflow30d: totalOut, + inflowRate: totalIn, + outflowRate: totalOut, + incomingTransfers: recentIncoming, + outgoingTransfers: recentOutgoing, + } +} diff --git a/lib/integrations.ts b/lib/integrations.ts index 4609e25..b38aadf 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -3,7 +3,7 @@ */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, IntegrationSource } from './types' -import type { SafeBalance } from './api/safe-client' +import type { SafeBalance, TransferSummary } from './api/safe-client' // ─── Safe Balances β†’ Funnel Nodes ──────────────────────────── @@ -11,6 +11,7 @@ export function safeBalancesToFunnels( balances: SafeBalance[], safeAddress: string, chainId: number, + transferSummary?: TransferSummary, startPosition = { x: 0, y: 100 } ): FlowNode[] { // Filter to non-zero balances with meaningful fiat value (> $1) @@ -31,13 +32,26 @@ export function safeBalancesToFunnels( lastFetchedAt: Date.now(), } + // Compute per-token inflow rate from transfer summary + let inflowRate = 0 + if (transferSummary) { + const tokenTransfers = transferSummary.incomingTransfers.filter(t => { + if (b.tokenAddress === null) return t.tokenAddress === null + return t.tokenAddress?.toLowerCase() === b.tokenAddress?.toLowerCase() + }) + inflowRate = tokenTransfers.reduce((sum, t) => { + const decimals = t.tokenInfo?.decimals ?? (b.token?.decimals ?? 18) + return sum + parseFloat(t.value) / Math.pow(10, decimals) + }, 0) + } + const data: FunnelNodeData = { label: `${b.symbol} Treasury`, currentValue: fiatValue, minThreshold: Math.round(fiatValue * 0.2), maxThreshold: Math.round(fiatValue * 0.8), maxCapacity: Math.round(fiatValue * 1.5), - inflowRate: 0, + inflowRate, overflowAllocations: [], spendingAllocations: [], source, From 15b9ba62a678e5d5cfa9975dff5c6e1f9c7553b0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 18:57:08 +0000 Subject: [PATCH 4/5] Add rData analytics tracking and ecosystem footer link - Inject rdata.online/collect.js tracking script in layout - Add rData link to ecosystem footer Co-Authored-By: Claude Opus 4.6 --- app/layout.tsx | 3 +++ app/page.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/app/layout.tsx b/app/layout.tsx index 04bcb72..757d1bd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -31,6 +31,9 @@ export default function RootLayout({ }>) { return ( + +