From f0d27bde16219b17b37f8cb70c447ad43e62108e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 13:16:55 -0800 Subject: [PATCH] feat: replace static rFunds diagram with interactive flow canvas Transform the Diagram tab from a read-only SVG into a full interactive canvas with draggable nodes, zoom/pan, Sankey-width edges with +/- allocation controls, slide-in editor panel, live simulation, node CRUD, keyboard shortcuts, and lz-string URL sharing. Co-Authored-By: Claude Opus 4.6 --- modules/rfunds/components/folk-funds-app.ts | 1075 +++++++++++++++---- modules/rfunds/components/funds.css | 134 ++- modules/rfunds/lib/map-flow.ts | 2 +- modules/rfunds/lib/presets.ts | 23 +- modules/rfunds/lib/types.ts | 17 +- modules/rfunds/mod.ts | 1 + 6 files changed, 1032 insertions(+), 220 deletions(-) diff --git a/modules/rfunds/components/folk-funds-app.ts b/modules/rfunds/components/folk-funds-app.ts index 48fd18d..109cfec 100644 --- a/modules/rfunds/components/folk-funds-app.ts +++ b/modules/rfunds/components/folk-funds-app.ts @@ -11,9 +11,9 @@ * mode — "demo" to use hardcoded demo data (no API) */ -import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "../lib/types"; -import { computeSufficiencyState, computeSystemSufficiency } from "../lib/simulation"; -import { demoNodes } from "../lib/presets"; +import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData } 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"; interface FlowSummary { @@ -72,6 +72,31 @@ class FolkFundsApp extends HTMLElement { private loading = false; private error = ""; + // Canvas state + private canvasZoom = 1; + private canvasPanX = 0; + private canvasPanY = 0; + private selectedNodeId: string | null = null; + private draggingNodeId: string | null = null; + private dragStartX = 0; + private dragStartY = 0; + private dragNodeStartX = 0; + private dragNodeStartY = 0; + private isPanning = false; + private panStartX = 0; + private panStartY = 0; + private panStartPanX = 0; + private panStartPanY = 0; + private editingNodeId: string | null = null; + private isSimulating = false; + private simInterval: ReturnType | null = null; + private canvasInitialized = false; + + // Bound handlers for cleanup + private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; + private _boundPointerMove: ((e: PointerEvent) => void) | null = null; + private _boundPointerUp: ((e: PointerEvent) => void) | null = null; + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -508,234 +533,843 @@ class FolkFundsApp extends HTMLElement { return (node.data as any).label || id; } - // ─── Diagram tab ────────────────────────────────────── + // ─── Diagram tab (interactive canvas) ───────────────── private renderDiagramTab(): string { if (this.nodes.length === 0) { return '
No nodes to display.
'; } - const sources = this.nodes.filter((n) => n.type === "source"); - const funnels = this.nodes.filter((n) => n.type === "funnel"); - const outcomes = this.nodes.filter((n) => n.type === "outcome"); - - // Layout constants - const NODE_W = 200; - const SOURCE_H = 54; - const FUNNEL_H = 120; - const OUTCOME_H = 70; - const PAD = 60; - const ROW_GAP = 160; - const COL_GAP = 40; - - // Compute layers: root funnels (no overflow targeting them) vs child funnels - const overflowTargets = new Set(); - funnels.forEach((n) => { - const d = n.data as FunnelNodeData; - d.overflowAllocations?.forEach((a) => overflowTargets.add(a.targetId)); - }); - const rootFunnels = funnels.filter((n) => !overflowTargets.has(n.id)); - const childFunnels = funnels.filter((n) => overflowTargets.has(n.id)); - - // Assign positions - const positions = new Map(); - - const placeRow = (nodes: { id: string }[], y: number, h: number) => { - const totalW = nodes.length * NODE_W + (nodes.length - 1) * COL_GAP; - const startX = PAD + Math.max(0, (svgW - 2 * PAD - totalW) / 2); - nodes.forEach((n, i) => { - positions.set(n.id, { x: startX + i * (NODE_W + COL_GAP), y, w: NODE_W, h }); - }); - }; - - // Estimate SVG size - const maxCols = Math.max(sources.length, rootFunnels.length, childFunnels.length, outcomes.length, 1); - const svgW = Math.max(800, maxCols * (NODE_W + COL_GAP) + 2 * PAD); - let currentY = PAD; - - if (sources.length > 0) { placeRow(sources, currentY, SOURCE_H); currentY += SOURCE_H + ROW_GAP; } - if (rootFunnels.length > 0) { placeRow(rootFunnels, currentY, FUNNEL_H); currentY += FUNNEL_H + ROW_GAP; } - if (childFunnels.length > 0) { placeRow(childFunnels, currentY, FUNNEL_H); currentY += FUNNEL_H + ROW_GAP; } - if (outcomes.length > 0) { placeRow(outcomes, currentY, OUTCOME_H); currentY += OUTCOME_H + PAD; } - const svgH = currentY; - - // Build SVG - const defs = ` - - - - - - - - - - - - - `; - - // Edges - let edges = ""; - - // Source → Funnel edges - sources.forEach((sn) => { - const sd = sn.data as SourceNodeData; - const sp = positions.get(sn.id); - if (!sp) return; - sd.targetAllocations?.forEach((alloc) => { - const tp = positions.get(alloc.targetId); - if (!tp) return; - const x1 = sp.x + sp.w / 2; - const y1 = sp.y + sp.h; - const x2 = tp.x + tp.w / 2; - const y2 = tp.y; - const cy1 = y1 + (y2 - y1) * 0.4; - const cy2 = y1 + (y2 - y1) * 0.6; - edges += ``; - edges += `${alloc.percentage}%`; - }); - }); - - // Overflow edges (funnel → funnel) - funnels.forEach((fn) => { - const fd = fn.data as FunnelNodeData; - const fp = positions.get(fn.id); - if (!fp) return; - fd.overflowAllocations?.forEach((alloc) => { - const tp = positions.get(alloc.targetId); - if (!tp) return; - const x1 = fp.x + fp.w / 2; - const y1 = fp.y + fp.h; - const x2 = tp.x + tp.w / 2; - const y2 = tp.y; - // If same row, draw sideways - if (Math.abs(y1 - fp.h - (y2 - tp?.h)) < 10) { - const sideY = fp.y + fp.h / 2; - const midX = (fp.x + fp.w + tp.x) / 2; - edges += ``; - edges += `${alloc.percentage}%`; - } else { - const cy1 = y1 + (y2 - y1) * 0.4; - const cy2 = y1 + (y2 - y1) * 0.6; - edges += ``; - edges += `${alloc.percentage}%`; - } - }); - }); - - // Spending edges (funnel → outcome) - funnels.forEach((fn) => { - const fd = fn.data as FunnelNodeData; - const fp = positions.get(fn.id); - if (!fp) return; - fd.spendingAllocations?.forEach((alloc, i) => { - const tp = positions.get(alloc.targetId); - if (!tp) return; - const x1 = fp.x + fp.w / 2; - const y1 = fp.y + fp.h; - const x2 = tp.x + tp.w / 2; - const y2 = tp.y; - const cy1 = y1 + (y2 - y1) * 0.4; - const cy2 = y1 + (y2 - y1) * 0.6; - edges += ``; - edges += `${alloc.percentage}%`; - }); - }); - - // Render nodes - let nodesSvg = ""; - - // Sources - sources.forEach((sn) => { - const sd = sn.data as SourceNodeData; - const p = positions.get(sn.id)!; - nodesSvg += ` - - ${this.esc(sd.label)} - $${sd.flowRate.toLocaleString()}/mo · ${sd.sourceType} - `; - }); - - // Funnels - funnels.forEach((fn) => { - const fd = fn.data as FunnelNodeData; - const p = positions.get(fn.id)!; - const sufficiency = computeSufficiencyState(fd); - const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant"; - const threshold = fd.sufficientThreshold ?? fd.maxThreshold; - const fillPct = Math.min(1, fd.currentValue / (fd.maxCapacity || 1)); - const fillH = fillPct * (p.h - 36); - const fillY = p.y + 36 + (p.h - 36) - fillH; - - const borderColor = fd.currentValue > fd.maxThreshold ? "#f59e0b" - : fd.currentValue < fd.minThreshold ? "#ef4444" - : isSufficient ? "#fbbf24" : "#0ea5e9"; - const fillColor = fd.currentValue > fd.maxThreshold ? "#f59e0b" - : fd.currentValue < fd.minThreshold ? "#ef4444" - : isSufficient ? "#fbbf24" : "#0ea5e9"; - const statusLabel = sufficiency === "abundant" ? "Abundant" - : sufficiency === "sufficient" ? "Sufficient" - : fd.currentValue < fd.minThreshold ? "Critical" : "Seeking"; - - nodesSvg += ` - ${isSufficient ? `` : ""} - - - ${this.esc(fd.label)} - $${Math.floor(fd.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} - ${statusLabel} - - - `; - }); - - // Outcomes - outcomes.forEach((on) => { - const od = on.data as OutcomeNodeData; - const p = positions.get(on.id)!; - const fillPct = od.fundingTarget > 0 ? Math.min(1, od.fundingReceived / od.fundingTarget) : 0; - const statusColor = od.status === "completed" ? "#10b981" - : od.status === "blocked" ? "#ef4444" - : od.status === "in-progress" ? "#3b82f6" : "#64748b"; - - nodesSvg += ` - - ${this.esc(od.label)} - - - ${Math.round(fillPct * 100)}% · $${Math.floor(od.fundingReceived).toLocaleString()} - `; - }); - - // Sufficiency badge const score = computeSystemSufficiency(this.nodes); const scorePct = Math.round(score * 100); const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444"; return ` -
- - ${defs} - ${edges} - ${nodesSvg} - - - ${scorePct}% - ENOUGH +
+
+
+
${scorePct}%
+
ENOUGH
+
+
+
+ + + +
+ + + +
+ + + + -
- Source - Funnel - Overflow - Spending - Outcome - Sufficient +
+
+ Source + Funnel + Overflow + Spending + Outcome + Sufficient +
+
+ +
`; } + // ─── Canvas lifecycle ───────────────────────────────── + + private initCanvas() { + this.drawCanvasContent(); + this.updateCanvasTransform(); + this.attachCanvasListeners(); + if (!this.canvasInitialized) { + this.canvasInitialized = true; + requestAnimationFrame(() => this.fitView()); + } + this.loadFromHash(); + } + + private drawCanvasContent() { + const edgeLayer = this.shadow.getElementById("edge-layer"); + const nodeLayer = this.shadow.getElementById("node-layer"); + if (!edgeLayer || !nodeLayer) return; + edgeLayer.innerHTML = this.renderAllEdges(); + nodeLayer.innerHTML = this.renderAllNodes(); + } + + private updateCanvasTransform() { + const g = this.shadow.getElementById("canvas-transform"); + if (g) g.setAttribute("transform", `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`); + } + + private fitView() { + const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null; + if (!svg || this.nodes.length === 0) return; + const rect = svg.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of this.nodes) { + const s = this.getNodeSize(n); + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + maxX = Math.max(maxX, n.position.x + s.w); + maxY = Math.max(maxY, n.position.y + s.h); + } + const pad = 60; + const contentW = maxX - minX + pad * 2; + const contentH = maxY - minY + pad * 2; + const scaleX = rect.width / contentW; + const scaleY = rect.height / contentH; + this.canvasZoom = Math.min(scaleX, scaleY, 1.5); + this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom; + this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom; + this.updateCanvasTransform(); + } + + private getNodeSize(n: FlowNode): { w: number; h: number } { + if (n.type === "source") return { w: 200, h: 60 }; + if (n.type === "funnel") return { w: 220, h: 160 }; + return { w: 200, h: 100 }; // outcome + } + + // ─── Canvas event wiring ────────────────────────────── + + private attachCanvasListeners() { + const svg = this.shadow.getElementById("flow-canvas"); + if (!svg) return; + + // Wheel zoom + svg.addEventListener("wheel", (e: WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const rect = svg.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const newZoom = Math.max(0.1, Math.min(4, this.canvasZoom * delta)); + // Zoom toward pointer + this.canvasPanX = mx - (mx - this.canvasPanX) * (newZoom / this.canvasZoom); + this.canvasPanY = my - (my - this.canvasPanY) * (newZoom / this.canvasZoom); + this.canvasZoom = newZoom; + this.updateCanvasTransform(); + }, { passive: false }); + + // Panning — pointerdown on SVG background + svg.addEventListener("pointerdown", (e: PointerEvent) => { + const target = e.target as Element; + // Only pan when clicking SVG background (not on a node) + if (target.closest(".flow-node")) return; + if (target.closest(".edge-ctrl-group")) return; + this.isPanning = true; + this.panStartX = e.clientX; + this.panStartY = e.clientY; + this.panStartPanX = this.canvasPanX; + this.panStartPanY = this.canvasPanY; + svg.classList.add("panning"); + svg.setPointerCapture(e.pointerId); + + // Deselect node + if (!target.closest(".flow-node")) { + this.selectedNodeId = null; + this.updateSelectionHighlight(); + } + }); + + // Global pointer move/up (for both panning and node drag) + this._boundPointerMove = (e: PointerEvent) => { + if (this.isPanning) { + this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); + this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); + this.updateCanvasTransform(); + return; + } + if (this.draggingNodeId) { + const dx = (e.clientX - this.dragStartX) / this.canvasZoom; + const dy = (e.clientY - this.dragStartY) / this.canvasZoom; + const node = this.nodes.find((n) => n.id === this.draggingNodeId); + if (node) { + node.position.x = this.dragNodeStartX + dx; + node.position.y = this.dragNodeStartY + dy; + this.updateNodePosition(node); + this.redrawEdges(); + } + } + }; + this._boundPointerUp = (e: PointerEvent) => { + if (this.isPanning) { + this.isPanning = false; + svg.classList.remove("panning"); + } + if (this.draggingNodeId) { + this.draggingNodeId = null; + svg.classList.remove("dragging"); + } + }; + svg.addEventListener("pointermove", this._boundPointerMove); + svg.addEventListener("pointerup", this._boundPointerUp); + + // Node interactions — delegate from node-layer + const nodeLayer = this.shadow.getElementById("node-layer"); + if (nodeLayer) { + nodeLayer.addEventListener("pointerdown", (e: PointerEvent) => { + const group = (e.target as Element).closest(".flow-node") as SVGGElement | null; + if (!group) return; + e.stopPropagation(); + const nodeId = group.dataset.nodeId; + if (!nodeId) return; + const node = this.nodes.find((n) => n.id === nodeId); + if (!node) return; + + // Select + this.selectedNodeId = nodeId; + this.updateSelectionHighlight(); + + // Start drag + this.draggingNodeId = nodeId; + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.dragNodeStartX = node.position.x; + this.dragNodeStartY = node.position.y; + svg.classList.add("dragging"); + svg.setPointerCapture(e.pointerId); + }); + + nodeLayer.addEventListener("dblclick", (e: MouseEvent) => { + const group = (e.target as Element).closest(".flow-node") as SVGGElement | null; + if (!group) return; + const nodeId = group.dataset.nodeId; + if (nodeId) this.openEditor(nodeId); + }); + } + + // Toolbar buttons + this.shadow.querySelectorAll("[data-canvas-action]").forEach((btn) => { + btn.addEventListener("click", () => { + const action = (btn as HTMLElement).dataset.canvasAction; + if (action === "add-source") this.addNode("source"); + else if (action === "add-funnel") this.addNode("funnel"); + else if (action === "add-outcome") this.addNode("outcome"); + else if (action === "sim") this.toggleSimulation(); + else if (action === "fit") this.fitView(); + else if (action === "share") this.shareState(); + else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); } + else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); } + }); + }); + + // Edge +/- buttons (delegated) + const edgeLayer = this.shadow.getElementById("edge-layer"); + if (edgeLayer) { + edgeLayer.addEventListener("click", (e: Event) => { + const btn = (e.target as Element).closest("[data-edge-action]") as HTMLElement | null; + if (!btn) return; + e.stopPropagation(); + const action = btn.dataset.edgeAction; // "inc" or "dec" + const fromId = btn.dataset.edgeFrom!; + const toId = btn.dataset.edgeTo!; + const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source"; + this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5); + }); + } + + // Keyboard + this._boundKeyDown = (e: KeyboardEvent) => { + // Skip if typing in editor input + const tag = (e.target as Element).tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + + 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); + } + + // ─── Node SVG rendering ─────────────────────────────── + + private renderAllNodes(): string { + return this.nodes.map((n) => this.renderNodeSvg(n)).join(""); + } + + private renderNodeSvg(n: FlowNode): string { + const sel = this.selectedNodeId === n.id; + if (n.type === "source") return this.renderSourceNodeSvg(n, sel); + if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel); + return this.renderOutcomeNodeSvg(n, sel); + } + + private renderSourceNodeSvg(n: FlowNode, selected: boolean): string { + const d = n.data as SourceNodeData; + const x = n.position.x, y = n.position.y, w = 200, h = 60; + const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; + const icon = icons[d.sourceType] || "\u{1F4B0}"; + return ` + + ${icon} + ${this.esc(d.label)} + $${d.flowRate.toLocaleString()}/mo + ${this.renderAllocBar(d.targetAllocations, w, h - 6)} + `; + } + + private renderFunnelNodeSvg(n: FlowNode, selected: boolean): string { + const d = n.data as FunnelNodeData; + const x = n.position.x, y = n.position.y, w = 220, h = 160; + const sufficiency = computeSufficiencyState(d); + const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant"; + const threshold = d.sufficientThreshold ?? d.maxThreshold; + const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); + + const borderColor = d.currentValue > d.maxThreshold ? "#f59e0b" + : d.currentValue < d.minThreshold ? "#ef4444" + : isSufficient ? "#fbbf24" : "#0ea5e9"; + const fillColor = borderColor; + const statusLabel = sufficiency === "abundant" ? "Abundant" + : sufficiency === "sufficient" ? "Sufficient" + : d.currentValue < d.minThreshold ? "Critical" : "Seeking"; + + // 3-zone background: drain (red), healthy (blue), overflow (amber) + const zoneH = h - 56; // area for zones (below header, above value text) + const zoneY = 32; + const drainPct = d.minThreshold / (d.maxCapacity || 1); + const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1); + const overflowPct = 1 - drainPct - healthyPct; + const drainH = zoneH * drainPct; + const healthyH = zoneH * healthyPct; + const overflowH = zoneH * overflowPct; + + // Fill level + const totalFillH = zoneH * fillPct; + const fillY = zoneY + zoneH - totalFillH; + + const glowClass = isSufficient ? " node-glow" : ""; + + return ` + ${isSufficient ? `` : ""} + + + + + + ${this.esc(d.label)} + ${statusLabel} + $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} + + + `; + } + + private renderOutcomeNodeSvg(n: FlowNode, selected: boolean): string { + const d = n.data as OutcomeNodeData; + const x = n.position.x, y = n.position.y, w = 200, h = 100; + const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0; + const statusColor = d.status === "completed" ? "#10b981" + : d.status === "blocked" ? "#ef4444" + : d.status === "in-progress" ? "#3b82f6" : "#64748b"; + + let phaseBars = ""; + if (d.phases && d.phases.length > 0) { + const phaseW = (w - 20) / d.phases.length; + phaseBars = d.phases.map((p, i) => { + const unlocked = d.fundingReceived >= p.fundingThreshold; + return ``; + }).join(""); + phaseBars += `${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases`; + } + + return ` + + + ${this.esc(d.label)} + + + ${Math.round(fillPct * 100)}% — $${Math.floor(d.fundingReceived).toLocaleString()} + ${phaseBars} + `; + } + + private renderAllocBar(allocs: { percentage: number; color: string }[], parentW: number, y: number): string { + if (!allocs || allocs.length === 0) return ""; + let bar = ""; + let cx = 10; + const barW = parentW - 20; + for (const a of allocs) { + const segW = barW * (a.percentage / 100); + bar += ``; + cx += segW; + } + return bar; + } + + // ─── Edge rendering ─────────────────────────────────── + + private renderAllEdges(): string { + let html = ""; + // 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); + 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 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, + alloc.color || "#10b981", strokeW, false, + alloc.percentage, n.id, alloc.targetId, "source", + ); + } + } + if (n.type === "funnel") { + const d = n.data as FunnelNodeData; + const s = this.getNodeSize(n); + // Overflow edges + 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 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, + alloc.color || "#f59e0b", strokeW, true, + alloc.percentage, n.id, alloc.targetId, "overflow", + ); + } + // Spending edges + 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 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, + alloc.color || "#8b5cf6", strokeW, false, + alloc.percentage, n.id, alloc.targetId, "spending", + ); + } + } + } + return html; + } + + private renderEdgePath( + x1: number, y1: number, x2: number, y2: number, + color: string, strokeW: number, dashed: boolean, + pct: number, fromId: string, toId: string, edgeType: string, + ): string { + const cy1 = y1 + (y2 - y1) * 0.4; + const cy2 = y1 + (y2 - y1) * 0.6; + const midX = (x1 + x2) / 2; + const midY = (y1 + y2) / 2; + const dash = dashed ? ' stroke-dasharray="6 3"' : ""; + return ` + + + + ${pct}% + + + + + + + + + + + `; + } + + private redrawEdges() { + const edgeLayer = this.shadow.getElementById("edge-layer"); + if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); + } + + // ─── Selection highlight ────────────────────────────── + + private updateSelectionHighlight() { + const nodeLayer = this.shadow.getElementById("node-layer"); + if (!nodeLayer) return; + nodeLayer.querySelectorAll(".flow-node").forEach((g) => { + const el = g as SVGGElement; + const isSelected = el.dataset.nodeId === this.selectedNodeId; + el.classList.toggle("selected", isSelected); + const bg = el.querySelector(".node-bg") as SVGRectElement | null; + if (bg) { + if (isSelected) { + bg.setAttribute("stroke", "#6366f1"); + bg.setAttribute("stroke-width", "3"); + } else { + // Restore original color + const node = this.nodes.find((n) => n.id === el.dataset.nodeId); + if (node) { + const origColor = this.getNodeBorderColor(node); + bg.setAttribute("stroke", origColor); + bg.setAttribute("stroke-width", node.type === "outcome" ? "1.5" : "2"); + } + } + } + }); + } + + private getNodeBorderColor(n: FlowNode): string { + if (n.type === "source") return "#10b981"; + if (n.type === "funnel") { + const d = n.data as FunnelNodeData; + const suf = computeSufficiencyState(d); + const isSuf = suf === "sufficient" || suf === "abundant"; + return d.currentValue > d.maxThreshold ? "#f59e0b" + : d.currentValue < d.minThreshold ? "#ef4444" + : isSuf ? "#fbbf24" : "#0ea5e9"; + } + const d = n.data as OutcomeNodeData; + return d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b"; + } + + // ─── Node position update (direct DOM, no re-render) ── + + private updateNodePosition(n: FlowNode) { + const nodeLayer = this.shadow.getElementById("node-layer"); + if (!nodeLayer) return; + const g = nodeLayer.querySelector(`[data-node-id="${n.id}"]`) as SVGGElement | null; + if (g) g.setAttribute("transform", `translate(${n.position.x},${n.position.y})`); + } + + // ─── Allocation adjustment ──────────────────────────── + + private handleAdjustAllocation(fromId: string, toId: string, allocType: string, delta: number) { + const node = this.nodes.find((n) => n.id === fromId); + if (!node) return; + + let allocs: { targetId: string; percentage: number; color: string }[]; + if (allocType === "source") { + allocs = (node.data as SourceNodeData).targetAllocations; + } else if (allocType === "overflow") { + allocs = (node.data as FunnelNodeData).overflowAllocations; + } else { + allocs = (node.data as FunnelNodeData).spendingAllocations; + } + + const idx = allocs.findIndex((a) => a.targetId === toId); + if (idx < 0) return; + + const newPct = Math.max(1, Math.min(99, allocs[idx].percentage + delta)); + const oldPct = allocs[idx].percentage; + const diff = newPct - oldPct; + allocs[idx].percentage = newPct; + + // Proportionally rebalance siblings + const siblings = allocs.filter((_, i) => i !== idx); + const sibTotal = siblings.reduce((s, a) => s + a.percentage, 0); + if (sibTotal > 0) { + for (const sib of siblings) { + sib.percentage = Math.max(1, Math.round(sib.percentage - diff * (sib.percentage / sibTotal))); + } + } + + // Normalize to exactly 100 + const total = allocs.reduce((s, a) => s + a.percentage, 0); + if (total !== 100 && allocs.length > 1) { + const last = allocs.find((_, i) => i !== idx) || allocs[allocs.length - 1]; + last.percentage += 100 - total; + last.percentage = Math.max(1, last.percentage); + } + + this.redrawEdges(); + this.refreshEditorIfOpen(fromId); + } + + // ─── Editor panel ───────────────────────────────────── + + private openEditor(nodeId: string) { + this.editingNodeId = nodeId; + const panel = this.shadow.getElementById("editor-panel"); + if (!panel) return; + const node = this.nodes.find((n) => n.id === nodeId); + if (!node) return; + + let content = `
+ ${this.esc((node.data as any).label || node.type)} + +
`; + + if (node.type === "source") content += this.renderSourceEditor(node); + else if (node.type === "funnel") content += this.renderFunnelEditor(node); + else content += this.renderOutcomeEditor(node); + + content += `
+ +
`; + + panel.innerHTML = content; + panel.classList.add("open"); + this.attachEditorListeners(panel, node); + } + + private closeEditor() { + this.editingNodeId = null; + const panel = this.shadow.getElementById("editor-panel"); + if (panel) { panel.classList.remove("open"); panel.innerHTML = ""; } + } + + private refreshEditorIfOpen(nodeId: string) { + if (this.editingNodeId === nodeId) this.openEditor(nodeId); + } + + private renderSourceEditor(n: FlowNode): string { + const d = n.data as SourceNodeData; + let html = ` +
+
+
+
+
+
`; + html += this.renderAllocEditor("Target Allocations", d.targetAllocations); + return html; + } + + private renderFunnelEditor(n: FlowNode): string { + const d = n.data as FunnelNodeData; + return ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.renderAllocEditor("Overflow Allocations", d.overflowAllocations)} + ${this.renderAllocEditor("Spending Allocations", d.spendingAllocations)}`; + } + + private renderOutcomeEditor(n: FlowNode): string { + const d = n.data as OutcomeNodeData; + let html = ` +
+
+
+
+
+
+
+
+
+
`; + if (d.phases && d.phases.length > 0) { + html += `
Phases
`; + for (const p of d.phases) { + const unlocked = d.fundingReceived >= p.fundingThreshold; + html += `
+
${this.esc(p.name)} — $${p.fundingThreshold.toLocaleString()}
+ ${p.tasks.map((t) => `
${t.completed ? "✅" : "⬜"} ${this.esc(t.label)}
`).join("")} +
`; + } + html += `
`; + } + return html; + } + + private renderAllocEditor(title: string, allocs: { targetId: string; percentage: number; color: string }[]): string { + if (!allocs || allocs.length === 0) return ""; + let html = `
${title}
`; + for (const a of allocs) { + html += `
+ + ${this.esc(this.getNodeLabel(a.targetId))} + ${a.percentage}% +
`; + } + html += `
`; + return html; + } + + private attachEditorListeners(panel: HTMLElement, node: FlowNode) { + // Close button + panel.querySelector('[data-editor-action="close"]')?.addEventListener("click", () => this.closeEditor()); + + // Delete button + panel.querySelector('[data-editor-action="delete"]')?.addEventListener("click", () => { + this.deleteNode(node.id); + this.closeEditor(); + }); + + // Input changes — live update + const inputs = panel.querySelectorAll(".editor-input, .editor-select"); + inputs.forEach((input) => { + input.addEventListener("change", () => { + const field = (input as HTMLElement).dataset.field; + if (!field) return; + const val = (input as HTMLInputElement).value; + const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"]; + (node.data as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; + this.drawCanvasContent(); + this.updateSufficiencyBadge(); + }); + }); + } + + // ─── Node CRUD ──────────────────────────────────────── + + private addNode(type: "source" | "funnel" | "outcome") { + // Place at center of current viewport + const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null; + const rect = svg?.getBoundingClientRect(); + const cx = rect ? (rect.width / 2 - this.canvasPanX) / this.canvasZoom : 400; + const cy = rect ? (rect.height / 2 - this.canvasPanY) / this.canvasZoom : 300; + + const id = `${type}-${Date.now().toString(36)}`; + let data: any; + if (type === "source") { + data = { label: "New Source", flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData; + } else if (type === "funnel") { + data = { + label: "New Funnel", currentValue: 0, minThreshold: 5000, maxThreshold: 20000, + maxCapacity: 30000, inflowRate: 500, sufficientThreshold: 15000, dynamicOverflow: false, + overflowAllocations: [], spendingAllocations: [], + } as FunnelNodeData; + } else { + data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started" } as OutcomeNodeData; + } + + this.nodes.push({ id, type, position: { x: cx - 100, y: cy - 50 }, data }); + this.drawCanvasContent(); + this.selectedNodeId = id; + this.updateSelectionHighlight(); + this.openEditor(id); + } + + private deleteNode(nodeId: string) { + this.nodes = this.nodes.filter((n) => n.id !== nodeId); + // Clean up allocations pointing to deleted node + for (const n of this.nodes) { + if (n.type === "source") { + const d = n.data as SourceNodeData; + d.targetAllocations = d.targetAllocations.filter((a) => a.targetId !== nodeId); + } + if (n.type === "funnel") { + const d = n.data as FunnelNodeData; + d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId); + d.spendingAllocations = d.spendingAllocations.filter((a) => a.targetId !== nodeId); + } + } + if (this.selectedNodeId === nodeId) this.selectedNodeId = null; + this.drawCanvasContent(); + this.updateSufficiencyBadge(); + } + + // ─── Simulation ─────────────────────────────────────── + + private toggleSimulation() { + this.isSimulating = !this.isSimulating; + const btn = this.shadow.getElementById("sim-btn"); + if (btn) btn.textContent = this.isSimulating ? "Pause" : "Play"; + + if (this.isSimulating) { + this.simInterval = setInterval(() => { + this.nodes = simulateTick(this.nodes); + this.updateCanvasLive(); + }, 100); + } else { + if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; } + } + } + + /** Update canvas nodes and edges without full innerHTML rebuild during simulation */ + private updateCanvasLive() { + const nodeLayer = this.shadow.getElementById("node-layer"); + if (!nodeLayer) return; + + // Rebuild node SVG content (can't do partial DOM updates easily for SVG text) + nodeLayer.innerHTML = this.renderAllNodes(); + this.redrawEdges(); + this.updateSufficiencyBadge(); + } + + private updateSufficiencyBadge() { + const score = computeSystemSufficiency(this.nodes); + const scorePct = Math.round(score * 100); + const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444"; + const badge = this.shadow.getElementById("badge-score"); + if (badge) { + badge.textContent = `${scorePct}%`; + badge.style.color = scoreColor; + } + } + + // ─── URL sharing ────────────────────────────────────── + + private shareState() { + try { + const LZString = (window as any).LZString; + if (!LZString) { + // Fallback: copy JSON directly + const json = JSON.stringify(this.nodes); + navigator.clipboard.writeText(window.location.href.split("#")[0] + "#flow=" + btoa(json)); + return; + } + const json = JSON.stringify(this.nodes); + const compressed = LZString.compressToEncodedURIComponent(json); + const url = window.location.href.split("#")[0] + "#flow=" + compressed; + history.replaceState(null, "", url); + navigator.clipboard.writeText(url); + } catch { + // Silent fail + } + } + + private loadFromHash() { + try { + const hash = window.location.hash; + if (!hash.startsWith("#flow=")) return; + const payload = hash.slice(6); + let json: string; + const LZString = (window as any).LZString; + if (LZString) { + json = LZString.decompressFromEncodedURIComponent(payload) || ""; + } else { + json = atob(payload); + } + if (!json) return; + const nodes = JSON.parse(json) as FlowNode[]; + if (Array.isArray(nodes) && nodes.length > 0) { + this.nodes = nodes; + this.drawCanvasContent(); + this.fitView(); + } + } catch { + // Invalid hash data — ignore + } + } + // ─── River tab ──────────────────────────────────────── private renderRiverTab(): string { @@ -826,6 +1460,8 @@ class FolkFundsApp extends HTMLElement { el.addEventListener("click", () => { const newTab = (el as HTMLElement).dataset.tab as Tab; if (newTab === this.tab) return; + // Cleanup old canvas state + if (this.tab === "diagram") this.cleanupCanvas(); this.tab = newTab; this.render(); @@ -840,11 +1476,22 @@ class FolkFundsApp extends HTMLElement { this.mountRiver(); } + // Initialize interactive canvas when diagram tab is active + if (this.tab === "diagram" && this.nodes.length > 0) { + this.initCanvas(); + } + // Create flow button (landing page, auth-gated) const createBtn = this.shadow.querySelector('[data-action="create-flow"]'); createBtn?.addEventListener("click", () => this.handleCreateFlow()); } + private cleanupCanvas() { + if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; } + this.isSimulating = false; + if (this._boundKeyDown) { document.removeEventListener("keydown", this._boundKeyDown); this._boundKeyDown = null; } + } + private async handleCreateFlow() { const token = getAccessToken(); if (!token) return; diff --git a/modules/rfunds/components/funds.css b/modules/rfunds/components/funds.css index 9a3b55a..270f4e1 100644 --- a/modules/rfunds/components/funds.css +++ b/modules/rfunds/components/funds.css @@ -140,7 +140,134 @@ .funds-status--seeking { color: #0ea5e9; } .funds-status--critical { color: #ef4444; } -/* ── Diagram tab ────────────────────────────────────── */ +/* ── Interactive canvas (Diagram tab) ───────────────── */ +.funds-canvas-container { + position: relative; height: 70vh; min-height: 400px; + background: #0f172a; border-radius: 12px; border: 1px solid #334155; + overflow: hidden; user-select: none; +} + +.funds-canvas-svg { + width: 100%; height: 100%; display: block; + cursor: grab; +} +.funds-canvas-svg.panning { cursor: grabbing; } +.funds-canvas-svg.dragging { cursor: move; } + +/* Toolbar — top-right overlay */ +.funds-canvas-toolbar { + position: absolute; top: 10px; right: 10px; z-index: 10; + display: flex; gap: 4px; flex-wrap: wrap; align-items: center; +} +.funds-canvas-btn { + padding: 5px 10px; border: 1px solid #475569; border-radius: 6px; + background: #1e293b; color: #e2e8f0; font-size: 11px; font-weight: 500; + cursor: pointer; white-space: nowrap; transition: background 0.15s, border-color 0.15s; +} +.funds-canvas-btn:hover { background: #334155; border-color: #64748b; } +.funds-canvas-btn--source { border-color: #10b981; color: #6ee7b7; } +.funds-canvas-btn--source:hover { background: #064e3b; } +.funds-canvas-btn--funnel { border-color: #3b82f6; color: #93c5fd; } +.funds-canvas-btn--funnel:hover { background: #1e3a5f; } +.funds-canvas-btn--outcome { border-color: #ec4899; color: #f9a8d4; } +.funds-canvas-btn--outcome:hover { background: #4a1942; } +.funds-canvas-btn--active { background: #4f46e5; border-color: #6366f1; color: #fff; } +.funds-canvas-sep { + width: 1px; height: 20px; background: #334155; margin: 0 4px; +} + +/* SVG node styles */ +.flow-node { cursor: pointer; } +.flow-node:hover .node-bg { filter: brightness(1.15); } +.flow-node.selected .node-bg { stroke: #6366f1; stroke-width: 3; } +.node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); } + +/* Editor panel — right side slide-in */ +.funds-editor-panel { + position: absolute; top: 0; right: 0; bottom: 0; width: 320px; z-index: 20; + background: #1e293b; border-left: 1px solid #334155; + transform: translateX(100%); transition: transform 0.25s ease; + overflow-y: auto; padding: 16px; + display: flex; flex-direction: column; gap: 12px; +} +.funds-editor-panel.open { transform: translateX(0); } + +.editor-header { + display: flex; align-items: center; justify-content: space-between; +} +.editor-title { font-size: 14px; font-weight: 600; color: #e2e8f0; } +.editor-close { + background: none; border: none; color: #94a3b8; font-size: 18px; cursor: pointer; padding: 2px 6px; +} +.editor-close:hover { color: #e2e8f0; } + +.editor-field { display: flex; flex-direction: column; gap: 4px; } +.editor-label { font-size: 11px; color: #94a3b8; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } +.editor-input { + padding: 6px 10px; border: 1px solid #334155; border-radius: 6px; + background: #0f172a; color: #e2e8f0; font-size: 13px; +} +.editor-input:focus { outline: none; border-color: #6366f1; } +.editor-select { + padding: 6px 10px; border: 1px solid #334155; border-radius: 6px; + background: #0f172a; color: #e2e8f0; font-size: 13px; +} +.editor-alloc-row { + display: flex; align-items: center; gap: 6px; font-size: 12px; color: #94a3b8; +} +.editor-alloc-row .editor-input { width: 60px; text-align: right; } +.editor-alloc-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; } +.editor-section { + padding-top: 8px; border-top: 1px solid #334155; +} +.editor-section-title { font-size: 12px; font-weight: 600; color: #64748b; margin-bottom: 6px; } +.editor-btn { + padding: 6px 14px; border: 1px solid #475569; border-radius: 6px; + background: #334155; color: #e2e8f0; font-size: 12px; cursor: pointer; + transition: background 0.15s; +} +.editor-btn:hover { background: #475569; } +.editor-btn--danger { border-color: #ef4444; color: #fca5a5; } +.editor-btn--danger:hover { background: #7f1d1d; } + +/* Edge +/- controls */ +.edge-controls { + display: flex; align-items: center; gap: 2px; font-size: 11px; +} +.edge-btn { + width: 18px; height: 18px; border: 1px solid #475569; border-radius: 4px; + background: #1e293b; color: #e2e8f0; font-size: 12px; cursor: pointer; + display: flex; align-items: center; justify-content: center; padding: 0; +} +.edge-btn:hover { background: #334155; } +.edge-pct { color: #e2e8f0; font-weight: 600; min-width: 30px; text-align: center; } + +/* Legend — bottom-left */ +.funds-canvas-legend { + position: absolute; bottom: 10px; left: 10px; z-index: 10; + display: flex; flex-wrap: wrap; gap: 12px; + font-size: 11px; color: #94a3b8; background: rgba(15,23,42,0.85); + padding: 6px 10px; border-radius: 8px; +} +.funds-canvas-legend-item { display: flex; align-items: center; gap: 4px; } +.funds-canvas-legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; } + +/* Zoom controls — bottom-right */ +.funds-canvas-zoom { + position: absolute; bottom: 10px; right: 10px; z-index: 10; + display: flex; gap: 4px; +} + +/* Sufficiency badge — top-left */ +.funds-canvas-badge { + position: absolute; top: 10px; left: 10px; z-index: 10; + background: rgba(15,23,42,0.85); border-radius: 10px; padding: 8px 14px; + display: flex; align-items: center; gap: 8px; +} +.funds-canvas-badge__score { font-size: 20px; font-weight: 700; } +.funds-canvas-badge__label { font-size: 10px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; } + +/* Legacy diagram (kept for compat) */ .funds-diagram { overflow-x: auto; } .funds-diagram svg { display: block; margin: 0 auto; } .funds-diagram__legend { @@ -177,4 +304,9 @@ .funds-features__grid { grid-template-columns: 1fr; } .funds-cards { grid-template-columns: 1fr; } .funds-tabs { flex-wrap: wrap; } + .funds-canvas-container { height: 50vh; min-height: 300px; } + .funds-canvas-toolbar { flex-wrap: wrap; gap: 3px; top: 6px; right: 6px; } + .funds-canvas-btn { padding: 4px 7px; font-size: 10px; } + .funds-editor-panel { width: 100%; } + .funds-canvas-legend { font-size: 10px; gap: 8px; } } diff --git a/modules/rfunds/lib/map-flow.ts b/modules/rfunds/lib/map-flow.ts index 801666f..2306625 100644 --- a/modules/rfunds/lib/map-flow.ts +++ b/modules/rfunds/lib/map-flow.ts @@ -21,7 +21,7 @@ export function mapFlowToNodes(apiData: any): FlowNode[] { data: { label: src.label || src.name || "Source", flowRate: src.flowRate ?? src.amount ?? 0, - sourceType: src.sourceType || "recurring", + sourceType: src.sourceType || "card", targetAllocations: (src.targetAllocations || src.allocations || []).map((a: any, i: number) => ({ targetId: a.targetId, percentage: a.percentage, diff --git a/modules/rfunds/lib/presets.ts b/modules/rfunds/lib/presets.ts index 3e8f915..ea8ca07 100644 --- a/modules/rfunds/lib/presets.ts +++ b/modules/rfunds/lib/presets.ts @@ -2,7 +2,7 @@ * Demo presets — ported from rfunds-online/lib/presets.ts. */ -import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "./types"; +import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData } from "./types"; export const SPENDING_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#06b6d4", "#10b981", "#6366f1"]; export const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc2626", "#ea580c"]; @@ -11,7 +11,7 @@ export const demoNodes: FlowNode[] = [ { id: "revenue", type: "source", position: { x: 660, y: -200 }, data: { - label: "Revenue Stream", flowRate: 5000, sourceType: "recurring", + label: "Revenue Stream", flowRate: 5000, sourceType: "card", targetAllocations: [{ targetId: "treasury", percentage: 100, color: "#10b981" }], } as SourceNodeData, }, @@ -67,7 +67,24 @@ export const demoNodes: FlowNode[] = [ } as FunnelNodeData, }, { id: "pg-infra", type: "outcome", position: { x: -50, y: 900 }, - data: { label: "Infrastructure", description: "Core infrastructure development", fundingReceived: 22000, fundingTarget: 30000, status: "in-progress" } as OutcomeNodeData }, + data: { + label: "Infrastructure", description: "Core infrastructure development", + fundingReceived: 22000, fundingTarget: 30000, status: "in-progress", + phases: [ + { name: "Foundation", fundingThreshold: 10000, tasks: [ + { label: "Server provisioning", completed: true }, + { label: "CI/CD pipeline", completed: true }, + ] }, + { name: "Scaling", fundingThreshold: 20000, tasks: [ + { label: "Load balancer setup", completed: true }, + { label: "Database replication", completed: false }, + ] }, + { name: "Hardening", fundingThreshold: 30000, tasks: [ + { label: "Security audit", completed: false }, + { label: "Disaster recovery", completed: false }, + ] }, + ], + } as OutcomeNodeData }, { id: "pg-education", type: "outcome", position: { x: 180, y: 900 }, data: { label: "Education", description: "Developer education programs", fundingReceived: 12000, fundingTarget: 20000, status: "in-progress" } as OutcomeNodeData }, { id: "pg-tooling", type: "outcome", position: { x: 410, y: 900 }, diff --git a/modules/rfunds/lib/types.ts b/modules/rfunds/lib/types.ts index 440c815..6c54f03 100644 --- a/modules/rfunds/lib/types.ts +++ b/modules/rfunds/lib/types.ts @@ -51,12 +51,24 @@ export interface FunnelNodeData { [key: string]: unknown; } +export interface PhaseTask { + label: string; + completed: boolean; +} + +export interface OutcomePhase { + name: string; + fundingThreshold: number; + tasks: PhaseTask[]; +} + export interface OutcomeNodeData { label: string; description?: string; fundingReceived: number; fundingTarget: number; status: "not-started" | "in-progress" | "completed" | "blocked"; + phases?: OutcomePhase[]; source?: IntegrationSource; [key: string]: unknown; } @@ -64,8 +76,11 @@ export interface OutcomeNodeData { export interface SourceNodeData { label: string; flowRate: number; - sourceType: "recurring" | "one-time" | "treasury"; + sourceType: "card" | "safe_wallet" | "ridentity" | "unconfigured"; targetAllocations: SourceAllocation[]; + walletAddress?: string; + chainId?: number; + safeAddress?: string; [key: string]: unknown; } diff --git a/modules/rfunds/mod.ts b/modules/rfunds/mod.ts index dd17a78..d2a136a 100644 --- a/modules/rfunds/mod.ts +++ b/modules/rfunds/mod.ts @@ -189,6 +189,7 @@ routes.delete("/api/space-flows/:flowId", async (c) => { // ─── Page routes ──────────────────────────────────────── const fundsScripts = ` + `;