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