/** * — main rFunds application component. * * Views: * "landing" — TBFF info hero + flow list cards * "detail" — Flow detail with tabs: Table | River | Transactions * * Attributes: * space — space slug * flow-id — if set, go straight to detail view * mode — "demo" to use hardcoded demo data (no API) */ 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 { id: string; name: string; label?: string; status?: string; funnelCount?: number; outcomeCount?: number; totalValue?: number; } interface Transaction { id: string; type: string; amount: number; from?: string; to?: string; timestamp: string; description?: string; } type View = "landing" | "detail"; type Tab = "diagram" | "table" | "river" | "transactions"; // ─── Auth helpers (reads EncryptID session from localStorage) ── function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null { try { const raw = localStorage.getItem("encryptid_session"); if (!raw) return null; const session = JSON.parse(raw); if (!session?.accessToken) return null; return session; } catch { return null; } } function isAuthenticated(): boolean { return getSession() !== null; } function getAccessToken(): string | null { return getSession()?.accessToken ?? null; } function getUsername(): string | null { return getSession()?.claims?.username ?? null; } class FolkFundsApp extends HTMLElement { private shadow: ShadowRoot; private space = ""; private view: View = "landing"; private tab: Tab = "diagram"; private flowId = ""; private isDemo = false; private flows: FlowSummary[] = []; private nodes: FlowNode[] = []; private flowName = ""; private transactions: Transaction[] = []; private txLoaded = false; 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" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.flowId = this.getAttribute("flow-id") || ""; this.isDemo = this.getAttribute("mode") === "demo" || this.space === "demo"; if (this.isDemo) { this.view = "detail"; this.flowName = "TBFF Demo Flow"; this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } })); this.render(); } else if (this.flowId) { this.view = "detail"; this.loadFlow(this.flowId); } else { this.view = "landing"; this.loadFlows(); } } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/funds/); return match ? `/${match[1]}/funds` : ""; } private async loadFlows() { this.loading = true; this.render(); try { const base = this.getApiBase(); const params = this.space ? `?space=${encodeURIComponent(this.space)}` : ""; const res = await fetch(`${base}/api/flows${params}`); if (res.ok) { const data = await res.json(); this.flows = Array.isArray(data) ? data : (data.flows || []); } } catch { // Flow service unavailable — landing page still works with demo link } this.loading = false; this.render(); } private async loadFlow(flowId: string) { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/flows/${encodeURIComponent(flowId)}`); if (res.ok) { const data = await res.json(); this.nodes = mapFlowToNodes(data); this.flowName = data.name || data.label || flowId; } else { this.error = `Flow not found (${res.status})`; } } catch { this.error = "Failed to load flow"; } this.loading = false; this.render(); } private async loadTransactions() { if (this.txLoaded || this.isDemo) return; this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/flows/${encodeURIComponent(this.flowId)}/transactions`); if (res.ok) { const data = await res.json(); this.transactions = Array.isArray(data) ? data : (data.transactions || []); } } catch { // Transactions unavailable } this.txLoaded = true; this.loading = false; this.render(); } private getCssPath(): string { // In rSpace: /modules/rfunds/funds.css | Standalone: /modules/rfunds/funds.css // The shell always serves from /modules/rfunds/ in both modes return "/modules/rfunds/funds.css"; } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading && this.view === "landing" ? '
Loading...
' : ""} ${this.renderView()} `; this.attachListeners(); } private renderView(): string { if (this.view === "detail") return this.renderDetail(); return this.renderLanding(); } // ─── Landing page ────────────────────────────────────── private renderLanding(): string { const demoUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/demo` : "/demo"; const authed = isAuthenticated(); const username = getUsername(); return `
Flows
Demo ${authed ? `` : `Sign in to create flows` }
Design transparent resource flows with sufficiency-based cascading. Funnels fill to their threshold, then overflow routes surplus to the next layer — ensuring every level has enough before abundance cascades forward.
💰

Sources

Revenue streams split across funnels by configurable allocation percentages.

🏛

Funnels

Budget buckets with min/max thresholds and sufficiency-based overflow cascading.

🎯

Outcomes

Funding targets that receive spending allocations. Track progress toward each goal.

🌊

River View

Animated sankey diagram showing live fund flows through your entire system.

Enoughness

System-wide sufficiency scoring. Golden glow when funnels reach their threshold.

${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}

${authed ? `Signed in as ${this.esc(username || "")}` : ""}
${this.flows.length > 0 ? `
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
` : `
${authed ? `

No flows in this space yet.

Explore the demo or create your first flow.

` : `

Sign in to see your space’s flows, or explore the demo.

` }
`}

How TBFF Works

1

Define Sources

Add revenue streams — grants, donations, sales, or any recurring income — with allocation splits.

2

Configure Funnels

Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.

3

Track Outcomes

Funding targets receive allocations as funnels reach sufficiency. Watch the river flow in real time.

`; } private renderFlowCard(f: FlowSummary): string { const detailUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/flow/${encodeURIComponent(f.id)}` : `/flow/${encodeURIComponent(f.id)}`; const value = f.totalValue != null ? `$${Math.floor(f.totalValue).toLocaleString()}` : ""; return `
${this.esc(f.name || f.label || f.id)}
${value ? `
${value}
` : ""}
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""} ${f.outcomeCount != null ? ` · ${f.outcomeCount} outcomes` : ""} ${f.status ? ` · ${f.status}` : ""}
`; } // ─── Detail view with tabs ───────────────────────────── private renderDetail(): string { const backUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/` : "/"; return `
← Flows ${this.esc(this.flowName || "Flow Detail")} ${this.isDemo ? 'Demo' : ""}
${this.loading ? '
Loading...
' : this.renderTab()}
`; } private renderTab(): string { if (this.tab === "diagram") return this.renderDiagramTab(); if (this.tab === "river") return this.renderRiverTab(); if (this.tab === "transactions") return this.renderTransactionsTab(); return this.renderTableTab(); } // ─── Table tab ──────────────────────────────────────── private renderTableTab(): string { const funnels = this.nodes.filter((n) => n.type === "funnel"); const outcomes = this.nodes.filter((n) => n.type === "outcome"); const sources = this.nodes.filter((n) => n.type === "source"); return `
${sources.length > 0 ? `

Sources

${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
` : ""}

Funnels

${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}

Outcomes

${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
`; } private renderSourceCard(data: SourceNodeData, id: string): string { const allocations = data.targetAllocations || []; return `
💰 ${this.esc(data.label)} ${data.sourceType}
$${data.flowRate.toLocaleString()} /month
${allocations.length > 0 ? `
${allocations.map((a) => `
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
`).join("")}
` : ""}
`; } private renderFunnelCard(data: FunnelNodeData, id: string): string { const sufficiency = computeSufficiencyState(data); const threshold = data.sufficientThreshold ?? data.maxThreshold; const fillPct = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100); const suffPct = Math.min(100, (data.currentValue / (threshold || 1)) * 100); const statusClass = sufficiency === "abundant" ? "funds-status--abundant" : sufficiency === "sufficient" ? "funds-status--sufficient" : data.currentValue < data.minThreshold ? "funds-status--critical" : "funds-status--seeking"; const statusLabel = sufficiency === "abundant" ? "Abundant" : sufficiency === "sufficient" ? "Sufficient" : data.currentValue < data.minThreshold ? "Critical" : "Seeking"; return `
🏛 ${this.esc(data.label)} ${statusLabel}
$${Math.floor(data.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}
${Math.round(suffPct)}% sufficiency
Min: $${Math.floor(data.minThreshold).toLocaleString()} Max: $${Math.floor(data.maxThreshold).toLocaleString()} Cap: $${Math.floor(data.maxCapacity).toLocaleString()}
${data.overflowAllocations.length > 0 ? `
Overflow
${data.overflowAllocations.map((a) => `
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
`).join("")}
` : ""} ${data.spendingAllocations.length > 0 ? `
Spending
${data.spendingAllocations.map((a) => `
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
`).join("")}
` : ""}
`; } private renderOutcomeCard(data: OutcomeNodeData, id: string): string { const fillPct = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0; const statusColor = data.status === "completed" ? "#10b981" : data.status === "blocked" ? "#ef4444" : data.status === "in-progress" ? "#3b82f6" : "#64748b"; return `
🎯 ${this.esc(data.label)} ${data.status}
${data.description ? `
${this.esc(data.description)}
` : ""}
$${Math.floor(data.fundingReceived).toLocaleString()} / $${Math.floor(data.fundingTarget).toLocaleString()}
${Math.round(fillPct)}% funded
`; } private getNodeLabel(id: string): string { const node = this.nodes.find((n) => n.id === id); if (!node) return id; return (node.data as any).label || id; } // ─── Diagram tab (interactive canvas) ───────────────── private renderDiagramTab(): string { if (this.nodes.length === 0) { return '
No nodes to display.
'; } const score = computeSystemSufficiency(this.nodes); const scorePct = Math.round(score * 100); const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444"; return `
${scorePct}%
ENOUGH
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 { return `
`; } private mountRiver() { const mount = this.shadow.getElementById("river-mount"); if (!mount) return; // Check if already mounted if (mount.querySelector("folk-budget-river")) return; const river = document.createElement("folk-budget-river") as any; river.setAttribute("simulate", "true"); mount.appendChild(river); // Pass nodes after the element is connected requestAnimationFrame(() => { if (typeof river.setNodes === "function") { river.setNodes(this.nodes.map((n) => ({ ...n, data: { ...n.data } }))); } }); } // ─── Transactions tab ───────────────────────────────── private renderTransactionsTab(): string { if (this.isDemo) { return `

Transaction history is not available in demo mode.

`; } if (!this.txLoaded) { return '
Loading transactions...
'; } if (this.transactions.length === 0) { return `

No transactions yet for this flow.

`; } return `
${this.transactions.map((tx) => `
${tx.type === "deposit" ? "⬆" : tx.type === "withdraw" ? "⬇" : "🔄"}
${this.esc(tx.description || tx.type)}
${tx.from ? `From: ${this.esc(tx.from)}` : ""} ${tx.to ? ` → ${this.esc(tx.to)}` : ""}
${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
${this.formatTime(tx.timestamp)}
`).join("")}
`; } private formatTime(ts: string): string { try { const d = new Date(ts); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return `${diffDays}d ago`; return d.toLocaleDateString(); } catch { return ts; } } // ─── Event listeners ────────────────────────────────── private attachListeners() { // Tab switching this.shadow.querySelectorAll("[data-tab]").forEach((el) => { 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(); if (newTab === "transactions" && !this.txLoaded) { this.loadTransactions(); } }); }); // Mount river component when river tab is active if (this.tab === "river" && this.nodes.length > 0) { 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; const name = prompt("Flow name:"); if (!name) return; this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/flows`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ name }), }); if (res.ok) { const data = await res.json(); const flowId = data.id || data.flowId; // Associate with current space if (flowId && this.space) { await fetch(`${base}/api/space-flows`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ space: this.space, flowId }), }); } // Navigate to the new flow if (flowId) { const detailUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/flow/${encodeURIComponent(flowId)}` : `/flow/${encodeURIComponent(flowId)}`; window.location.href = detailUrl; return; } } else { const err = await res.json().catch(() => ({})); this.error = (err as any).error || `Failed to create flow (${res.status})`; } } catch { this.error = "Failed to create flow"; } this.loading = false; this.render(); } private esc(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } } customElements.define("folk-funds-app", FolkFundsApp);