From 03a7b6d0633d8b93a1098b3099b9f8b0a6be8406 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 14:30:14 -0800 Subject: [PATCH] feat: add explicit ports with drag-to-connect wiring to rFunds canvas Adds named, colored ports to every node type (source outflow, funnel inflow/spending/overflow, outcome inflow/overflow) with a full wiring state machine supporting both click-to-wire and drag-to-wire interaction. Edges now originate from specific port positions. Outcomes gain overflow allocations so fully-funded outcomes can cascade surplus onward. Co-Authored-By: Claude Opus 4.6 --- modules/rfunds/components/folk-funds-app.ts | 307 ++++++++++++++++++-- modules/rfunds/components/funds.css | 29 ++ modules/rfunds/lib/simulation.ts | 70 +++-- modules/rfunds/lib/types.ts | 34 +++ 4 files changed, 403 insertions(+), 37 deletions(-) diff --git a/modules/rfunds/components/folk-funds-app.ts b/modules/rfunds/components/folk-funds-app.ts index 109cfec..7c50627 100644 --- a/modules/rfunds/components/folk-funds-app.ts +++ b/modules/rfunds/components/folk-funds-app.ts @@ -11,7 +11,8 @@ * mode — "demo" to use hardcoded demo data (no API) */ -import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData } from "../lib/types"; +import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind } from "../lib/types"; +import { PORT_DEFS } from "../lib/types"; import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { mapFlowToNodes } from "../lib/map-flow"; @@ -92,6 +93,14 @@ class FolkFundsApp extends HTMLElement { private simInterval: ReturnType | null = null; private canvasInitialized = false; + // Wiring state + private wiringActive = false; + private wiringSourceNodeId: string | null = null; + private wiringSourcePortKind: PortKind | null = null; + private wiringDragging = false; + private wiringPointerX = 0; + private wiringPointerY = 0; + // Bound handlers for cleanup private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; private _boundPointerMove: ((e: PointerEvent) => void) | null = null; @@ -564,6 +573,7 @@ class FolkFundsApp extends HTMLElement { + @@ -667,6 +677,10 @@ class FolkFundsApp extends HTMLElement { // Only pan when clicking SVG background (not on a node) if (target.closest(".flow-node")) return; if (target.closest(".edge-ctrl-group")) return; + + // Cancel wiring on empty canvas click + if (this.wiringActive) { this.cancelWiring(); return; } + this.isPanning = true; this.panStartX = e.clientX; this.panStartY = e.clientY; @@ -684,6 +698,12 @@ class FolkFundsApp extends HTMLElement { // Global pointer move/up (for both panning and node drag) this._boundPointerMove = (e: PointerEvent) => { + if (this.wiringActive && this.wiringDragging) { + this.wiringPointerX = e.clientX; + this.wiringPointerY = e.clientY; + this.updateWiringTempLine(); + return; + } if (this.isPanning) { this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); @@ -703,6 +723,20 @@ class FolkFundsApp extends HTMLElement { } }; this._boundPointerUp = (e: PointerEvent) => { + if (this.wiringActive && this.wiringDragging) { + // Hit-test: did we release on a compatible input port? + const el = this.shadow.elementFromPoint(e.clientX, e.clientY); + const portGroup = el?.closest?.(".port-group") as SVGGElement | null; + if (portGroup && portGroup.dataset.portDir === "in" && portGroup.dataset.nodeId !== this.wiringSourceNodeId) { + this.completeWiring(portGroup.dataset.nodeId!); + } else { + // Fall back to click-to-wire mode (source still glowing) + this.wiringDragging = false; + const wireLayer = this.shadow.getElementById("wire-layer"); + if (wireLayer) wireLayer.innerHTML = ""; + } + return; + } if (this.isPanning) { this.isPanning = false; svg.classList.remove("panning"); @@ -719,6 +753,36 @@ class FolkFundsApp extends HTMLElement { const nodeLayer = this.shadow.getElementById("node-layer"); if (nodeLayer) { nodeLayer.addEventListener("pointerdown", (e: PointerEvent) => { + // Check port interaction FIRST + const portGroup = (e.target as Element).closest(".port-group") as SVGGElement | null; + if (portGroup) { + e.stopPropagation(); + const portNodeId = portGroup.dataset.nodeId!; + const portKind = portGroup.dataset.portKind as PortKind; + const portDir = portGroup.dataset.portDir!; + + if (this.wiringActive) { + // Click-to-wire: complete on compatible input port + if (portDir === "in" && portNodeId !== this.wiringSourceNodeId) { + this.completeWiring(portNodeId); + } else { + this.cancelWiring(); + } + return; + } + + // Start wiring from output port + if (portDir === "out") { + this.enterWiring(portNodeId, portKind); + this.wiringDragging = true; + this.wiringPointerX = e.clientX; + this.wiringPointerY = e.clientY; + svg.setPointerCapture(e.pointerId); + return; + } + return; + } + const group = (e.target as Element).closest(".flow-node") as SVGGElement | null; if (!group) return; e.stopPropagation(); @@ -727,6 +791,12 @@ class FolkFundsApp extends HTMLElement { const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; + // If wiring is active and clicked on a node (not port), cancel + if (this.wiringActive) { + this.cancelWiring(); + return; + } + // Select this.selectedNodeId = nodeId; this.updateSelectionHighlight(); @@ -785,12 +855,15 @@ class FolkFundsApp extends HTMLElement { const tag = (e.target as Element).tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; - if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); } + if (e.key === "Escape") { + if (this.wiringActive) { this.cancelWiring(); return; } + this.closeEditor(); + } + else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); } else if (e.key === "Delete" || e.key === "Backspace") { if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); } else if (e.key === "f" || e.key === "F") this.fitView(); else if (e.key === "=" || e.key === "+") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); } else if (e.key === "-") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); } - else if (e.key === "Escape") this.closeEditor(); }; document.addEventListener("keydown", this._boundKeyDown); } @@ -819,6 +892,7 @@ class FolkFundsApp extends HTMLElement { ${this.esc(d.label)} $${d.flowRate.toLocaleString()}/mo ${this.renderAllocBar(d.targetAllocations, w, h - 6)} + ${this.renderPortsSvg(n)} `; } @@ -866,6 +940,7 @@ class FolkFundsApp extends HTMLElement { $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} + ${this.renderPortsSvg(n)} `; } @@ -895,6 +970,7 @@ class FolkFundsApp extends HTMLElement { ${Math.round(fillPct * 100)}% — $${Math.floor(d.fundingReceived).toLocaleString()} ${phaseBars} + ${this.renderPortsSvg(n)} `; } @@ -918,19 +994,17 @@ class FolkFundsApp extends HTMLElement { // Find max flow rate for Sankey width scaling const maxFlow = Math.max(1, ...this.nodes.filter((n) => n.type === "source").map((n) => (n.data as SourceNodeData).flowRate)); - // Source → target edges for (const n of this.nodes) { if (n.type === "source") { const d = n.data as SourceNodeData; - const s = this.getNodeSize(n); + const from = this.getPortPosition(n, "outflow"); for (const alloc of d.targetAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const ts = this.getNodeSize(target); + const to = this.getPortPosition(target, "inflow"); const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 8); html += this.renderEdgePath( - n.position.x + s.w / 2, n.position.y + s.h, - target.position.x + ts.w / 2, target.position.y, + from.x, from.y, to.x, to.y, alloc.color || "#10b981", strokeW, false, alloc.percentage, n.id, alloc.targetId, "source", ); @@ -938,34 +1012,50 @@ class FolkFundsApp extends HTMLElement { } if (n.type === "funnel") { const d = n.data as FunnelNodeData; - const s = this.getNodeSize(n); - // Overflow edges + // Overflow edges — from overflow port for (const alloc of d.overflowAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const ts = this.getNodeSize(target); + const from = this.getPortPosition(n, "overflow"); + const to = this.getPortPosition(target, "inflow"); const strokeW = Math.max(1.5, (alloc.percentage / 100) * 6); html += this.renderEdgePath( - n.position.x + s.w / 2, n.position.y + s.h, - target.position.x + ts.w / 2, target.position.y, + from.x, from.y, to.x, to.y, alloc.color || "#f59e0b", strokeW, true, alloc.percentage, n.id, alloc.targetId, "overflow", ); } - // Spending edges + // Spending edges — from spending port for (const alloc of d.spendingAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const ts = this.getNodeSize(target); + const from = this.getPortPosition(n, "spending"); + const to = this.getPortPosition(target, "inflow"); const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5); html += this.renderEdgePath( - n.position.x + s.w / 2, n.position.y + s.h, - target.position.x + ts.w / 2, target.position.y, + from.x, from.y, to.x, to.y, alloc.color || "#8b5cf6", strokeW, false, alloc.percentage, n.id, alloc.targetId, "spending", ); } } + // Outcome overflow edges + if (n.type === "outcome") { + const d = n.data as OutcomeNodeData; + const allocs = d.overflowAllocations || []; + for (const alloc of allocs) { + const target = this.nodes.find((t) => t.id === alloc.targetId); + if (!target) continue; + const from = this.getPortPosition(n, "overflow"); + const to = this.getPortPosition(target, "inflow"); + const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5); + html += this.renderEdgePath( + from.x, from.y, to.x, to.y, + alloc.color || "#f59e0b", strokeW, true, + alloc.percentage, n.id, alloc.targetId, "overflow", + ); + } + } } return html; } @@ -1043,6 +1133,173 @@ class FolkFundsApp extends HTMLElement { return d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b"; } + // ─── Port rendering & wiring ───────────────────────── + + private getPortDefs(nodeType: FlowNode["type"]): PortDefinition[] { + return PORT_DEFS[nodeType] || []; + } + + private getPortPosition(node: FlowNode, portKind: PortKind): { x: number; y: number } { + const s = this.getNodeSize(node); + const def = this.getPortDefs(node.type).find((p) => p.kind === portKind); + if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 }; + return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac }; + } + + private renderPortsSvg(n: FlowNode): string { + const s = this.getNodeSize(n); + const defs = this.getPortDefs(n.type); + return defs.map((p) => { + const cx = s.w * p.xFrac; + const cy = s.h * p.yFrac; + const arrow = p.dir === "out" + ? `` + : ``; + return ` + + + ${arrow} + `; + }).join(""); + } + + private enterWiring(nodeId: string, portKind: PortKind) { + this.wiringActive = true; + this.wiringSourceNodeId = nodeId; + this.wiringSourcePortKind = portKind; + this.wiringDragging = false; + const svg = this.shadow.getElementById("flow-canvas"); + if (svg) svg.classList.add("wiring"); + this.applyWiringClasses(); + } + + private cancelWiring() { + this.wiringActive = false; + this.wiringSourceNodeId = null; + this.wiringSourcePortKind = null; + this.wiringDragging = false; + const svg = this.shadow.getElementById("flow-canvas"); + if (svg) svg.classList.remove("wiring"); + const wireLayer = this.shadow.getElementById("wire-layer"); + if (wireLayer) wireLayer.innerHTML = ""; + this.clearWiringClasses(); + } + + private applyWiringClasses() { + const nodeLayer = this.shadow.getElementById("node-layer"); + if (!nodeLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return; + + const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId); + if (!sourceNode) return; + + const sourceDef = this.getPortDefs(sourceNode.type).find((p) => p.kind === this.wiringSourcePortKind); + const connectsTo = sourceDef?.connectsTo || []; + + nodeLayer.querySelectorAll(".port-group").forEach((g) => { + const el = g as SVGGElement; + const nid = el.dataset.nodeId!; + const pk = el.dataset.portKind as PortKind; + const pd = el.dataset.portDir!; + + if (nid === this.wiringSourceNodeId && pk === this.wiringSourcePortKind) { + el.classList.add("port-group--wiring-source"); + } else if (pd === "in" && connectsTo.includes(pk) && nid !== this.wiringSourceNodeId && !this.allocationExists(this.wiringSourceNodeId!, nid, this.wiringSourcePortKind!)) { + el.classList.add("port-group--wiring-target"); + } else { + el.classList.add("port-group--wiring-dimmed"); + } + }); + } + + private clearWiringClasses() { + const nodeLayer = this.shadow.getElementById("node-layer"); + if (!nodeLayer) return; + nodeLayer.querySelectorAll(".port-group").forEach((g) => { + g.classList.remove("port-group--wiring-source", "port-group--wiring-target", "port-group--wiring-dimmed"); + }); + } + + private completeWiring(targetNodeId: string) { + if (!this.wiringSourceNodeId || !this.wiringSourcePortKind) return; + + const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId); + const targetNode = this.nodes.find((n) => n.id === targetNodeId); + if (!sourceNode || !targetNode) { this.cancelWiring(); return; } + + // Determine allocation type and color + const portKind = this.wiringSourcePortKind; + if (sourceNode.type === "source" && portKind === "outflow") { + const d = sourceNode.data as SourceNodeData; + const color = SPENDING_COLORS[d.targetAllocations.length % SPENDING_COLORS.length] || "#10b981"; + d.targetAllocations.push({ targetId: targetNodeId, percentage: 0, color }); + this.normalizeAllocations(d.targetAllocations); + } else if (sourceNode.type === "funnel" && portKind === "overflow") { + const d = sourceNode.data as FunnelNodeData; + const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b"; + d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color }); + this.normalizeAllocations(d.overflowAllocations); + } else if (sourceNode.type === "funnel" && portKind === "spending") { + const d = sourceNode.data as FunnelNodeData; + const color = SPENDING_COLORS[d.spendingAllocations.length % SPENDING_COLORS.length] || "#8b5cf6"; + d.spendingAllocations.push({ targetId: targetNodeId, percentage: 0, color }); + this.normalizeAllocations(d.spendingAllocations); + } else if (sourceNode.type === "outcome" && portKind === "overflow") { + const d = sourceNode.data as OutcomeNodeData; + if (!d.overflowAllocations) d.overflowAllocations = []; + const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b"; + d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color }); + this.normalizeAllocations(d.overflowAllocations); + } + + this.cancelWiring(); + this.drawCanvasContent(); + this.openEditor(this.wiringSourceNodeId || sourceNode.id); + } + + private normalizeAllocations(allocs: { targetId: string; percentage: number; color: string }[]) { + if (allocs.length === 0) return; + const equal = Math.floor(100 / allocs.length); + const remainder = 100 - equal * allocs.length; + allocs.forEach((a, i) => { a.percentage = equal + (i === 0 ? remainder : 0); }); + } + + private allocationExists(fromId: string, toId: string, portKind: PortKind): boolean { + const node = this.nodes.find((n) => n.id === fromId); + if (!node) return false; + if (node.type === "source" && portKind === "outflow") { + return (node.data as SourceNodeData).targetAllocations.some((a) => a.targetId === toId); + } + if (node.type === "funnel" && portKind === "overflow") { + return (node.data as FunnelNodeData).overflowAllocations.some((a) => a.targetId === toId); + } + if (node.type === "funnel" && portKind === "spending") { + return (node.data as FunnelNodeData).spendingAllocations.some((a) => a.targetId === toId); + } + if (node.type === "outcome" && portKind === "overflow") { + return ((node.data as OutcomeNodeData).overflowAllocations || []).some((a) => a.targetId === toId); + } + return false; + } + + private updateWiringTempLine() { + const wireLayer = this.shadow.getElementById("wire-layer"); + if (!wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return; + + const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId); + if (!sourceNode) return; + const { x: x1, y: y1 } = this.getPortPosition(sourceNode, this.wiringSourcePortKind); + + const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom; + const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom; + + const cy1 = y1 + (y2 - y1) * 0.4; + const cy2 = y1 + (y2 - y1) * 0.6; + wireLayer.innerHTML = ``; + } + // ─── Node position update (direct DOM, no re-render) ── private updateNodePosition(n: FlowNode) { @@ -1062,7 +1319,11 @@ class FolkFundsApp extends HTMLElement { if (allocType === "source") { allocs = (node.data as SourceNodeData).targetAllocations; } else if (allocType === "overflow") { - allocs = (node.data as FunnelNodeData).overflowAllocations; + if (node.type === "outcome") { + allocs = (node.data as OutcomeNodeData).overflowAllocations || []; + } else { + allocs = (node.data as FunnelNodeData).overflowAllocations; + } } else { allocs = (node.data as FunnelNodeData).spendingAllocations; } @@ -1195,6 +1456,9 @@ class FolkFundsApp extends HTMLElement { } html += ``; } + if (d.overflowAllocations && d.overflowAllocations.length > 0) { + html += this.renderAllocEditor("Overflow Allocations", d.overflowAllocations); + } return html; } @@ -1257,7 +1521,7 @@ class FolkFundsApp extends HTMLElement { overflowAllocations: [], spendingAllocations: [], } as FunnelNodeData; } else { - data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started" } as OutcomeNodeData; + data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started", overflowAllocations: [] } as OutcomeNodeData; } this.nodes.push({ id, type, position: { x: cx - 100, y: cy - 50 }, data }); @@ -1280,6 +1544,10 @@ class FolkFundsApp extends HTMLElement { d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId); d.spendingAllocations = d.spendingAllocations.filter((a) => a.targetId !== nodeId); } + if (n.type === "outcome") { + const d = n.data as OutcomeNodeData; + if (d.overflowAllocations) d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId); + } } if (this.selectedNodeId === nodeId) this.selectedNodeId = null; this.drawCanvasContent(); @@ -1489,6 +1757,7 @@ class FolkFundsApp extends HTMLElement { private cleanupCanvas() { if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; } this.isSimulating = false; + if (this.wiringActive) this.cancelWiring(); if (this._boundKeyDown) { document.removeEventListener("keydown", this._boundKeyDown); this._boundKeyDown = null; } } diff --git a/modules/rfunds/components/funds.css b/modules/rfunds/components/funds.css index 270f4e1..2800e1f 100644 --- a/modules/rfunds/components/funds.css +++ b/modules/rfunds/components/funds.css @@ -297,6 +297,35 @@ .funds-tx__amount--negative { color: #ef4444; } .funds-tx__time { font-size: 11px; color: #64748b; white-space: nowrap; } +/* ── Port & wiring ──────────────────────────────────── */ +.port-group { pointer-events: all; } +.port-hit { cursor: crosshair; } +.port-dot { transition: r 0.15s, filter 0.15s; } +.port-group:hover .port-dot { r: 7; filter: drop-shadow(0 0 4px currentColor); } + +.port-group--wiring-source .port-dot { animation: port-glow 0.8s ease-in-out infinite; } +.port-group--wiring-target .port-dot { animation: port-breathe 1s ease-in-out infinite; } +.port-group--wiring-dimmed { opacity: 0.15; pointer-events: none; } + +.wiring-temp-path { + fill: none; stroke: #94a3b8; stroke-width: 2; stroke-dasharray: 8 4; + stroke-linecap: round; animation: wire-dash 0.6s linear infinite; +} + +.funds-canvas-svg.wiring { cursor: crosshair; } + +@keyframes port-glow { + 0%, 100% { filter: drop-shadow(0 0 4px currentColor); } + 50% { filter: drop-shadow(0 0 10px currentColor); } +} +@keyframes port-breathe { + 0%, 100% { opacity: 0.6; r: 5; } + 50% { opacity: 1; r: 7; } +} +@keyframes wire-dash { + to { stroke-dashoffset: -12; } +} + /* ── Mobile responsive ──────────────────────────────── */ @media (max-width: 768px) { .funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; } diff --git a/modules/rfunds/lib/simulation.ts b/modules/rfunds/lib/simulation.ts index 702578d..4298a54 100644 --- a/modules/rfunds/lib/simulation.ts +++ b/modules/rfunds/lib/simulation.ts @@ -150,29 +150,63 @@ export function simulateTick( updatedFunnels.set(node.id, data); } + // Process outcomes in Y-order (like funnels) so overflow can cascade + const outcomeNodes = nodes + .filter((n) => n.type === "outcome") + .sort((a, b) => a.position.y - b.position.y); + + const outcomeOverflowIncoming = new Map(); + const updatedOutcomes = new Map(); + + for (const node of outcomeNodes) { + const src = node.data as OutcomeNodeData; + const data: OutcomeNodeData = { ...src }; + + const incoming = (spendingIncoming.get(node.id) ?? 0) + + (overflowIncoming.get(node.id) ?? 0) + + (outcomeOverflowIncoming.get(node.id) ?? 0); + + if (incoming > 0) { + let newReceived = data.fundingReceived + incoming; + + // Overflow: if fully funded and has overflow allocations, distribute excess + const allocs = data.overflowAllocations; + if (allocs && allocs.length > 0 && data.fundingTarget > 0 && newReceived > data.fundingTarget) { + const excess = newReceived - data.fundingTarget; + for (const alloc of allocs) { + const share = excess * (alloc.percentage / 100); + outcomeOverflowIncoming.set(alloc.targetId, (outcomeOverflowIncoming.get(alloc.targetId) ?? 0) + share); + } + newReceived = data.fundingTarget; + } + + // Cap at 105% if no overflow allocations + if (!allocs || allocs.length === 0) { + newReceived = Math.min( + data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity, + newReceived, + ); + } + + data.fundingReceived = newReceived; + + if (data.fundingTarget > 0 && data.fundingReceived >= data.fundingTarget && data.status !== "blocked") { + data.status = "completed"; + } else if (data.fundingReceived > 0 && data.status === "not-started") { + data.status = "in-progress"; + } + } + + updatedOutcomes.set(node.id, data); + } + return nodes.map((node) => { if (node.type === "funnel" && updatedFunnels.has(node.id)) { return { ...node, data: updatedFunnels.get(node.id)! }; } - if (node.type === "outcome") { - const data = node.data as OutcomeNodeData; - const incoming = spendingIncoming.get(node.id) ?? 0; - if (incoming <= 0) return node; - - const newReceived = Math.min( - data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity, - data.fundingReceived + incoming, - ); - - 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"; - } - - return { ...node, data: { ...data, fundingReceived: newReceived, status: newStatus } }; + if (node.type === "outcome" && updatedOutcomes.has(node.id)) { + return { ...node, data: updatedOutcomes.get(node.id)! }; } return node; diff --git a/modules/rfunds/lib/types.ts b/modules/rfunds/lib/types.ts index 6c54f03..29f85e2 100644 --- a/modules/rfunds/lib/types.ts +++ b/modules/rfunds/lib/types.ts @@ -69,6 +69,7 @@ export interface OutcomeNodeData { fundingTarget: number; status: "not-started" | "in-progress" | "completed" | "blocked"; phases?: OutcomePhase[]; + overflowAllocations?: OverflowAllocation[]; source?: IntegrationSource; [key: string]: unknown; } @@ -91,3 +92,36 @@ export interface FlowNode { position: { x: number; y: number }; data: FunnelNodeData | OutcomeNodeData | SourceNodeData; } + +// ─── Port definitions ───────────────────────────────── + +export type PortDirection = "in" | "out"; +export type PortKind = "outflow" | "inflow" | "spending" | "overflow"; + +export interface PortDefinition { + kind: PortKind; + dir: PortDirection; + /** X offset as fraction of node width (0–1) */ + xFrac: number; + /** Y offset: 0 = top, 1 = bottom */ + yFrac: number; + color: string; + /** Which port kinds this output can wire to */ + connectsTo?: PortKind[]; +} + +/** Single source of truth for port positions, colors, and connectivity rules. */ +export const PORT_DEFS: Record = { + source: [ + { kind: "outflow", dir: "out", xFrac: 0.5, yFrac: 1, color: "#10b981", connectsTo: ["inflow"] }, + ], + funnel: [ + { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" }, + { kind: "spending", dir: "out", xFrac: 0.3, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] }, + { kind: "overflow", dir: "out", xFrac: 0.7, yFrac: 1, color: "#f59e0b", connectsTo: ["inflow"] }, + ], + outcome: [ + { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" }, + { kind: "overflow", dir: "out", xFrac: 0.5, yFrac: 1, color: "#f59e0b", connectsTo: ["inflow"] }, + ], +};