diff --git a/app/river/page.tsx b/app/river/page.tsx index af0d05c..12d7e96 100644 --- a/app/river/page.tsx +++ b/app/river/page.tsx @@ -7,6 +7,7 @@ import { demoNodes } from '@/lib/presets' 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 { computeSystemSufficiency } from '@/lib/simulation' import type { FlowNode, FunnelNodeData } from '@/lib/types' import type { BackendFlow } from '@/lib/api/flows-client' @@ -22,6 +23,19 @@ const BudgetRiver = dynamic(() => import('@/components/BudgetRiver'), { ), }) +function EnoughnessPill({ nodes }: { nodes: FlowNode[] }) { + const score = Math.round(computeSystemSufficiency(nodes) * 100) + const color = score >= 90 ? 'bg-amber-500/30 text-amber-300' : + score >= 60 ? 'bg-emerald-600/30 text-emerald-300' : + score >= 30 ? 'bg-amber-600/30 text-amber-300' : + 'bg-red-600/30 text-red-300' + return ( + + Enoughness: {score}% + + ) +} + export default function RiverPage() { const [mode, setMode] = useState<'demo' | 'live'>('demo') const [nodes, setNodes] = useState(demoNodes) @@ -209,6 +223,8 @@ export default function RiverPage() { | Budget River + | + {mode === 'live' && connectedChains.length > 0 && ( <> | @@ -347,6 +363,10 @@ export default function RiverPage() {
Outcome pool
+
+
+ Sufficient (golden) +
diff --git a/components/BudgetRiver.tsx b/components/BudgetRiver.tsx index 095e611..51c6505 100644 --- a/components/BudgetRiver.tsx +++ b/components/BudgetRiver.tsx @@ -1,7 +1,8 @@ 'use client' import { useState, useEffect, useMemo, useCallback } from 'react' -import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from '@/lib/types' +import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from '@/lib/types' +import { computeSufficiencyState, computeSystemSufficiency } from '@/lib/simulation' // ─── Layout Types ──────────────────────────────────────── @@ -35,6 +36,7 @@ interface FunnelLayout { segmentLength: number layer: number status: 'healthy' | 'overflow' | 'critical' + sufficiency: SufficiencyState } interface OutcomeLayout { @@ -95,9 +97,11 @@ const COLORS = { riverHealthy: ['#0ea5e9', '#06b6d4'], riverOverflow: ['#f59e0b', '#fbbf24'], riverCritical: ['#ef4444', '#f87171'], + riverSufficient: ['#fbbf24', '#10b981'], overflowBranch: '#f59e0b', spendingWaterfall: ['#8b5cf6', '#ec4899', '#06b6d4', '#3b82f6', '#10b981', '#6366f1'], outcomePool: '#3b82f6', + goldenGlow: '#fbbf24', bg: '#0f172a', text: '#e2e8f0', textMuted: '#94a3b8', @@ -216,6 +220,7 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { segmentLength: SEGMENT_LENGTH, layer, status, + sufficiency: computeSufficiencyState(data), }) }) } @@ -648,13 +653,26 @@ function SankeyWaterfall({ wf }: { wf: WaterfallLayout }) { } function RiverSegment({ funnel }: { funnel: FunnelLayout }) { - const { id, label, data, x, y, riverWidth, segmentLength, status } = funnel - const gradColors = status === 'overflow' ? COLORS.riverOverflow : + const { id, label, data, x, y, riverWidth, segmentLength, status, sufficiency } = funnel + const isSufficientOrAbundant = sufficiency === 'sufficient' || sufficiency === 'abundant' + const gradColors = isSufficientOrAbundant ? COLORS.riverSufficient : + status === 'overflow' ? COLORS.riverOverflow : status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy const thresholdMinY = y + riverWidth * 0.85 const thresholdMaxY = y + riverWidth * 0.15 + // Sufficiency ring: arc fill from 0 to sufficientThreshold + const sufficientThreshold = data.sufficientThreshold ?? data.maxThreshold + const sufficiencyFill = Math.min(1, data.currentValue / (sufficientThreshold || 1)) + + // Status pill text/color + const pillText = isSufficientOrAbundant ? 'ENOUGH' : + status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK' + const pillColor = isSufficientOrAbundant ? COLORS.goldenGlow : + status === 'overflow' ? '#f59e0b' : status === 'critical' ? '#ef4444' : '#10b981' + const pillWidth = isSufficientOrAbundant ? 42 : 32 + return ( @@ -666,6 +684,20 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) { + {/* Golden glow for sufficient/abundant funnels */} + {isSufficientOrAbundant && ( + + )} + {/* River glow */} + {/* Sufficiency ring: thin progress bar above the river */} + + = 1 ? COLORS.goldenGlow : '#06b6d4'} + opacity={0.8} + /> + {/* River body */} {/* Surface current lines */} @@ -739,10 +791,24 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) { /> MIN + {/* Dynamic overflow indicator */} + {data.dynamicOverflow && ( + + DYN + + )} + {/* Label */} - {status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'} + {pillText} ) @@ -1023,6 +1089,11 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver }, [isSimulating]) const layout = useMemo(() => computeLayout(animatedNodes), [animatedNodes]) + const systemSufficiency = useMemo(() => computeSystemSufficiency(animatedNodes), [animatedNodes]) + const sufficiencyPct = Math.round(systemSufficiency * 100) + const sufficiencyColor = sufficiencyPct >= 90 ? '#fbbf24' : + sufficiencyPct >= 60 ? '#10b981' : + sufficiencyPct >= 30 ? '#f59e0b' : '#ef4444' return (
@@ -1081,6 +1152,33 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver ))} + {/* System Enoughness Badge (top-right) */} + + {/* Background circle */} + + {/* Track ring */} + + {/* Progress ring */} + + {/* Percentage text */} + + {sufficiencyPct}% + + {/* Label */} + + ENOUGHNESS + + + {/* Title watermark */} = data.maxCapacity) return 'abundant' + if (data.currentValue >= threshold) return 'sufficient' + return 'seeking' +} + +/** + * Compute need-weights for a set of overflow target IDs. + * For funnels: need = max(0, 1 - currentValue / sufficientThreshold) + * For outcomes: need = max(0, 1 - fundingReceived / fundingTarget) + * Returns a Map of targetId → percentage (normalized to 100). + */ +export function computeNeedWeights( + targetIds: string[], + allNodes: FlowNode[], +): Map { + const nodeMap = new Map(allNodes.map(n => [n.id, n])) + const needs = new Map() + + for (const tid of targetIds) { + const node = nodeMap.get(tid) + if (!node) { needs.set(tid, 0); continue } + + if (node.type === 'funnel') { + const d = node.data as FunnelNodeData + const threshold = d.sufficientThreshold ?? d.maxThreshold + const need = Math.max(0, 1 - d.currentValue / (threshold || 1)) + needs.set(tid, need) + } else if (node.type === 'outcome') { + const d = node.data as OutcomeNodeData + const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1)) + needs.set(tid, need) + } else { + needs.set(tid, 0) + } + } + + // Normalize to percentages summing to 100 + const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0) + const weights = new Map() + if (totalNeed === 0) { + // Equal distribution when all targets are satisfied + const equal = targetIds.length > 0 ? 100 / targetIds.length : 0 + targetIds.forEach(id => weights.set(id, equal)) + } else { + needs.forEach((need, id) => { + weights.set(id, (need / totalNeed) * 100) + }) + } + return weights +} + +/** + * Compute system-wide sufficiency score (0-1). + * Averages fill ratios of all funnels and progress ratios of all outcomes. + */ +export function computeSystemSufficiency(nodes: FlowNode[]): number { + let sum = 0 + let count = 0 + + for (const node of nodes) { + if (node.type === 'funnel') { + const d = node.data as FunnelNodeData + const threshold = d.sufficientThreshold ?? d.maxThreshold + sum += Math.min(1, d.currentValue / (threshold || 1)) + count++ + } else if (node.type === 'outcome') { + const d = node.data as OutcomeNodeData + sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1)) + count++ + } + } + + return count > 0 ? sum / count : 0 +} + export function simulateTick( nodes: FlowNode[], config: SimulationConfig = DEFAULT_CONFIG, @@ -55,12 +137,28 @@ export function simulateTick( // 4. Distribute overflow when above maxThreshold if (value > data.maxThreshold && data.overflowAllocations.length > 0) { const excess = value - data.maxThreshold - for (const alloc of data.overflowAllocations) { - const share = excess * (alloc.percentage / 100) - overflowIncoming.set( - alloc.targetId, - (overflowIncoming.get(alloc.targetId) ?? 0) + share, - ) + + if (data.dynamicOverflow) { + // Dynamic overflow: route by need-weight instead of fixed percentages + const targetIds = data.overflowAllocations.map(a => a.targetId) + const needWeights = computeNeedWeights(targetIds, nodes) + for (const alloc of data.overflowAllocations) { + const weight = needWeights.get(alloc.targetId) ?? 0 + const share = excess * (weight / 100) + overflowIncoming.set( + alloc.targetId, + (overflowIncoming.get(alloc.targetId) ?? 0) + share, + ) + } + } else { + // Fixed-percentage overflow (existing behavior) + for (const alloc of data.overflowAllocations) { + const share = excess * (alloc.percentage / 100) + overflowIncoming.set( + alloc.targetId, + (overflowIncoming.get(alloc.targetId) ?? 0) + share, + ) + } } value = data.maxThreshold } diff --git a/lib/types.ts b/lib/types.ts index 2d5843b..139e0e5 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -115,6 +115,8 @@ export interface SpendingAllocation { color: string } +export type SufficiencyState = 'seeking' | 'sufficient' | 'abundant' + export interface FunnelNodeData { label: string currentValue: number @@ -122,6 +124,9 @@ export interface FunnelNodeData { maxThreshold: number maxCapacity: number inflowRate: number + // Sufficiency layer + sufficientThreshold?: number // level at which funnel has "enough" (defaults to maxThreshold) + dynamicOverflow?: boolean // when true, overflow routes by need instead of fixed % // Overflow goes SIDEWAYS to other funnels overflowAllocations: OverflowAllocation[] // Spending goes DOWN to outcomes/outputs