/** * — 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, SourceNodeData } from "../lib/types"; import { computeSufficiencyState, computeSystemSufficiency } from "../lib/simulation"; import { demoNodes } 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 = ""; 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"; 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/funds/funds.css | Standalone: /modules/funds/funds.css // The shell always serves from /modules/funds/ in both modes return "/modules/funds/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 `

rFunds

Token Bonding Flow Funnel

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.

Try the Demo → ${authed ? `` : `Sign in to create flows` }
💰

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 `
← All 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 ────────────────────────────────────── 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
Source Funnel Overflow Spending Outcome Sufficient
`; } // ─── 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; 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(); } // Create flow button (landing page, auth-gated) const createBtn = this.shadow.querySelector('[data-action="create-flow"]'); createBtn?.addEventListener("click", () => this.handleCreateFlow()); } 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);