rspace-online/modules/funds/components/folk-funds-app.ts

910 lines
34 KiB
TypeScript

/**
* <folk-funds-app> — 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 = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
*, *::before, *::after { box-sizing: border-box; }
</style>
<link rel="stylesheet" href="${this.getCssPath()}">
${this.error ? `<div class="funds-error">${this.esc(this.error)}</div>` : ""}
${this.loading && this.view === "landing" ? '<div class="funds-loading">Loading...</div>' : ""}
${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 `
<div class="funds-landing">
<div class="funds-hero">
<h1 class="funds-hero__title">rFunds</h1>
<p class="funds-hero__subtitle">Token Bonding Flow Funnel</p>
<p class="funds-hero__desc">
Design transparent resource flows with sufficiency-based cascading.
Funnels fill to their threshold, then overflow routes surplus to the next layer &mdash;
ensuring every level has <em>enough</em> before abundance cascades forward.
</p>
<div class="funds-hero__actions">
<a href="${this.esc(demoUrl)}" class="funds-hero__cta">Try the Demo &rarr;</a>
${authed
? `<button class="funds-hero__cta funds-hero__cta--secondary" data-action="create-flow">Create Flow</button>`
: `<span class="funds-hero__auth-hint">Sign in to create flows</span>`
}
</div>
</div>
<div class="funds-features">
<div class="funds-features__grid">
<div class="funds-features__card">
<div class="funds-features__icon">&#x1F4B0;</div>
<h3>Sources</h3>
<p>Revenue streams split across funnels by configurable allocation percentages.</p>
</div>
<div class="funds-features__card">
<div class="funds-features__icon">&#x1F3DB;</div>
<h3>Funnels</h3>
<p>Budget buckets with min/max thresholds and sufficiency-based overflow cascading.</p>
</div>
<div class="funds-features__card">
<div class="funds-features__icon">&#x1F3AF;</div>
<h3>Outcomes</h3>
<p>Funding targets that receive spending allocations. Track progress toward each goal.</p>
</div>
<div class="funds-features__card">
<div class="funds-features__icon">&#x1F30A;</div>
<h3>River View</h3>
<p>Animated sankey diagram showing live fund flows through your entire system.</p>
</div>
<div class="funds-features__card">
<div class="funds-features__icon">&#x2728;</div>
<h3>Enoughness</h3>
<p>System-wide sufficiency scoring. Golden glow when funnels reach their threshold.</p>
</div>
</div>
</div>
<div class="funds-flows">
<div class="funds-flows__header">
<h2 class="funds-flows__heading">${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}</h2>
${authed ? `<span class="funds-flows__user">Signed in as ${this.esc(username || "")}</span>` : ""}
</div>
${this.flows.length > 0 ? `
<div class="funds-flows__grid">
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
</div>
` : `
<div class="funds-flows__empty">
${authed
? `<p>No flows in this space yet.</p>
<p><a href="${this.esc(demoUrl)}">Explore the demo</a> or create your first flow.</p>`
: `<p>Sign in to see your space&rsquo;s flows, or <a href="${this.esc(demoUrl)}">explore the demo</a>.</p>`
}
</div>
`}
</div>
<div class="funds-about">
<h2 class="funds-about__heading">How TBFF Works</h2>
<div class="funds-about__steps">
<div class="funds-about__step">
<div class="funds-about__step-num">1</div>
<div>
<h3>Define Sources</h3>
<p>Add revenue streams &mdash; grants, donations, sales, or any recurring income &mdash; with allocation splits.</p>
</div>
</div>
<div class="funds-about__step">
<div class="funds-about__step-num">2</div>
<div>
<h3>Configure Funnels</h3>
<p>Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.</p>
</div>
</div>
<div class="funds-about__step">
<div class="funds-about__step-num">3</div>
<div>
<h3>Track Outcomes</h3>
<p>Funding targets receive allocations as funnels reach sufficiency. Watch the river flow in real time.</p>
</div>
</div>
</div>
</div>
</div>`;
}
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 `
<a href="${this.esc(detailUrl)}" class="funds-flow-card" data-flow="${this.esc(f.id)}">
<div class="funds-flow-card__name">${this.esc(f.name || f.label || f.id)}</div>
${value ? `<div class="funds-flow-card__value">${value}</div>` : ""}
<div class="funds-flow-card__meta">
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""}
${f.outcomeCount != null ? ` &middot; ${f.outcomeCount} outcomes` : ""}
${f.status ? ` &middot; ${f.status}` : ""}
</div>
</a>`;
}
// ─── Detail view with tabs ─────────────────────────────
private renderDetail(): string {
const backUrl = this.getApiBase()
? `${this.getApiBase().replace(/\/funds$/, "")}/funds/`
: "/";
return `
<div class="funds-detail">
<div class="funds-detail__header">
<a href="${this.esc(backUrl)}" class="funds-detail__back">&larr; All Flows</a>
<h1 class="funds-detail__title">${this.esc(this.flowName || "Flow Detail")}</h1>
${this.isDemo ? '<span class="funds-detail__badge">Demo</span>' : ""}
</div>
<div class="funds-tabs">
<button class="funds-tab ${this.tab === "diagram" ? "funds-tab--active" : ""}" data-tab="diagram">Diagram</button>
<button class="funds-tab ${this.tab === "river" ? "funds-tab--active" : ""}" data-tab="river">River</button>
<button class="funds-tab ${this.tab === "table" ? "funds-tab--active" : ""}" data-tab="table">Table</button>
<button class="funds-tab ${this.tab === "transactions" ? "funds-tab--active" : ""}" data-tab="transactions">Transactions</button>
</div>
<div class="funds-tab-content">
${this.loading ? '<div class="funds-loading">Loading...</div>' : this.renderTab()}
</div>
</div>`;
}
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 `
<div class="funds-table">
${sources.length > 0 ? `
<div class="funds-section">
<h3 class="funds-section__title">Sources</h3>
<div class="funds-cards">
${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
</div>
</div>
` : ""}
<div class="funds-section">
<h3 class="funds-section__title">Funnels</h3>
<div class="funds-cards">
${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}
</div>
</div>
<div class="funds-section">
<h3 class="funds-section__title">Outcomes</h3>
<div class="funds-cards">
${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
</div>
</div>
</div>`;
}
private renderSourceCard(data: SourceNodeData, id: string): string {
const allocations = data.targetAllocations || [];
return `
<div class="funds-card">
<div class="funds-card__header">
<span class="funds-card__icon">&#x1F4B0;</span>
<span class="funds-card__label">${this.esc(data.label)}</span>
<span class="funds-card__type">${data.sourceType}</span>
</div>
<div class="funds-card__stat">
<span class="funds-card__stat-value">$${data.flowRate.toLocaleString()}</span>
<span class="funds-card__stat-label">/month</span>
</div>
${allocations.length > 0 ? `
<div class="funds-card__allocs">
${allocations.map((a) => `
<div class="funds-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
</div>
` : ""}
</div>`;
}
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 `
<div class="funds-card">
<div class="funds-card__header">
<span class="funds-card__icon">&#x1F3DB;</span>
<span class="funds-card__label">${this.esc(data.label)}</span>
<span class="funds-card__status ${statusClass}">${statusLabel}</span>
</div>
<div class="funds-card__bar-container">
<div class="funds-card__bar" style="width:${fillPct}%"></div>
<div class="funds-card__bar-threshold" style="left:${Math.min(100, (threshold / (data.maxCapacity || 1)) * 100)}%"></div>
</div>
<div class="funds-card__stats">
<div>
<span class="funds-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span>
<span class="funds-card__stat-label">/ $${Math.floor(threshold).toLocaleString()}</span>
</div>
<div>
<span class="funds-card__stat-value">${Math.round(suffPct)}%</span>
<span class="funds-card__stat-label">sufficiency</span>
</div>
</div>
<div class="funds-card__thresholds">
<span>Min: $${Math.floor(data.minThreshold).toLocaleString()}</span>
<span>Max: $${Math.floor(data.maxThreshold).toLocaleString()}</span>
<span>Cap: $${Math.floor(data.maxCapacity).toLocaleString()}</span>
</div>
${data.overflowAllocations.length > 0 ? `
<div class="funds-card__allocs">
<div class="funds-card__alloc-title">Overflow</div>
${data.overflowAllocations.map((a) => `
<div class="funds-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
</div>
` : ""}
${data.spendingAllocations.length > 0 ? `
<div class="funds-card__allocs">
<div class="funds-card__alloc-title">Spending</div>
${data.spendingAllocations.map((a) => `
<div class="funds-card__alloc">
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
</div>
` : ""}
</div>`;
}
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 `
<div class="funds-card">
<div class="funds-card__header">
<span class="funds-card__icon">&#x1F3AF;</span>
<span class="funds-card__label">${this.esc(data.label)}</span>
<span class="funds-card__status" style="color:${statusColor}">${data.status}</span>
</div>
${data.description ? `<div class="funds-card__desc">${this.esc(data.description)}</div>` : ""}
<div class="funds-card__bar-container">
<div class="funds-card__bar funds-card__bar--outcome" style="width:${fillPct}%;background:${statusColor}"></div>
</div>
<div class="funds-card__stats">
<div>
<span class="funds-card__stat-value">$${Math.floor(data.fundingReceived).toLocaleString()}</span>
<span class="funds-card__stat-label">/ $${Math.floor(data.fundingTarget).toLocaleString()}</span>
</div>
<div>
<span class="funds-card__stat-value">${Math.round(fillPct)}%</span>
<span class="funds-card__stat-label">funded</span>
</div>
</div>
</div>`;
}
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 '<div class="funds-loading">No nodes to display.</div>';
}
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<string>();
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<string, { x: number; y: number; w: number; h: number }>();
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 = `<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#10b981"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#f59e0b"/>
</marker>
<marker id="arrow-purple" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8b5cf6"/>
</marker>
</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 += `<path d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}" fill="none" stroke="#10b981" stroke-width="2.5" stroke-opacity="0.7" marker-end="url(#arrow-green)"/>`;
edges += `<text x="${(x1 + x2) / 2 + 8}" y="${(y1 + y2) / 2 - 4}" fill="#10b981" font-size="11" font-weight="500">${alloc.percentage}%</text>`;
});
});
// 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 += `<path d="M ${fp.x + fp.w} ${sideY} C ${midX} ${sideY}, ${midX} ${tp.y + tp.h / 2}, ${tp.x} ${tp.y + tp.h / 2}" fill="none" stroke="${alloc.color || "#f59e0b"}" stroke-width="2.5" stroke-opacity="0.7" stroke-dasharray="6 3" marker-end="url(#arrow-amber)"/>`;
edges += `<text x="${midX}" y="${sideY - 8}" fill="${alloc.color || "#f59e0b"}" font-size="11" font-weight="500" text-anchor="middle">${alloc.percentage}%</text>`;
} else {
const cy1 = y1 + (y2 - y1) * 0.4;
const cy2 = y1 + (y2 - y1) * 0.6;
edges += `<path d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}" fill="none" stroke="${alloc.color || "#f59e0b"}" stroke-width="2.5" stroke-opacity="0.7" stroke-dasharray="6 3" marker-end="url(#arrow-amber)"/>`;
edges += `<text x="${(x1 + x2) / 2 + 8}" y="${(y1 + y2) / 2 - 4}" fill="${alloc.color || "#f59e0b"}" font-size="11" font-weight="500">${alloc.percentage}%</text>`;
}
});
});
// 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 += `<path d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}" fill="none" stroke="${alloc.color || "#8b5cf6"}" stroke-width="2" stroke-opacity="0.6" marker-end="url(#arrow-purple)"/>`;
edges += `<text x="${(x1 + x2) / 2 + 8}" y="${(y1 + y2) / 2 - 4}" fill="${alloc.color || "#8b5cf6"}" font-size="10" font-weight="500">${alloc.percentage}%</text>`;
});
});
// Render nodes
let nodesSvg = "";
// Sources
sources.forEach((sn) => {
const sd = sn.data as SourceNodeData;
const p = positions.get(sn.id)!;
nodesSvg += `<g>
<rect x="${p.x}" y="${p.y}" width="${p.w}" height="${p.h}" rx="8" fill="#064e3b" stroke="#10b981" stroke-width="2"/>
<text x="${p.x + p.w / 2}" y="${p.y + 22}" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(sd.label)}</text>
<text x="${p.x + p.w / 2}" y="${p.y + 40}" text-anchor="middle" fill="#6ee7b7" font-size="11">$${sd.flowRate.toLocaleString()}/mo &middot; ${sd.sourceType}</text>
</g>`;
});
// 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 += `<g>
${isSufficient ? `<rect x="${p.x - 3}" y="${p.y - 3}" width="${p.w + 6}" height="${p.h + 6}" rx="12" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.5"/>` : ""}
<rect x="${p.x}" y="${p.y}" width="${p.w}" height="${p.h}" rx="10" fill="#1e293b" stroke="${borderColor}" stroke-width="2"/>
<rect x="${p.x + 2}" y="${fillY}" width="${p.w - 4}" height="${fillH}" rx="0" fill="${fillColor}" opacity="0.25"/>
<text x="${p.x + p.w / 2}" y="${p.y + 20}" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(fd.label)}</text>
<text x="${p.x + p.w / 2}" y="${p.y + p.h - 12}" text-anchor="middle" fill="#94a3b8" font-size="10">$${Math.floor(fd.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
<text x="${p.x + p.w - 8}" y="${p.y + 20}" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500">${statusLabel}</text>
<rect x="${p.x + 8}" y="${p.y + p.h - 6}" width="${p.w - 16}" height="3" rx="1.5" fill="#334155"/>
<rect x="${p.x + 8}" y="${p.y + p.h - 6}" width="${(p.w - 16) * fillPct}" height="3" rx="1.5" fill="${fillColor}"/>
</g>`;
});
// 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 += `<g>
<rect x="${p.x}" y="${p.y}" width="${p.w}" height="${p.h}" rx="8" fill="#1e293b" stroke="${statusColor}" stroke-width="1.5"/>
<text x="${p.x + p.w / 2}" y="${p.y + 20}" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">${this.esc(od.label)}</text>
<rect x="${p.x + 8}" y="${p.y + 32}" width="${p.w - 16}" height="5" rx="2.5" fill="#334155"/>
<rect x="${p.x + 8}" y="${p.y + 32}" width="${(p.w - 16) * fillPct}" height="5" rx="2.5" fill="${statusColor}" opacity="0.8"/>
<text x="${p.x + p.w / 2}" y="${p.y + 54}" text-anchor="middle" fill="#94a3b8" font-size="10">${Math.round(fillPct * 100)}% &middot; $${Math.floor(od.fundingReceived).toLocaleString()}</text>
</g>`;
});
// 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 `
<div class="funds-diagram" id="diagram-container">
<svg viewBox="0 0 ${svgW} ${svgH}" width="100%" style="max-height:70vh;background:#0f172a;border-radius:12px;border:1px solid #334155">
${defs}
${edges}
${nodesSvg}
<g transform="translate(${svgW - 70}, 16)">
<circle cx="24" cy="24" r="22" fill="#1e293b" stroke="#334155" stroke-width="1.5"/>
<text x="24" y="22" text-anchor="middle" fill="${scoreColor}" font-size="12" font-weight="700">${scorePct}%</text>
<text x="24" y="34" text-anchor="middle" fill="#94a3b8" font-size="7">ENOUGH</text>
</g>
</svg>
<div class="funds-diagram__legend">
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#10b981"></span>Source</span>
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#0ea5e9"></span>Funnel</span>
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#f59e0b"></span>Overflow</span>
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#8b5cf6"></span>Spending</span>
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#3b82f6"></span>Outcome</span>
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#fbbf24"></span>Sufficient</span>
</div>
</div>`;
}
// ─── River tab ────────────────────────────────────────
private renderRiverTab(): string {
return `<div class="funds-river-container" id="river-mount"></div>`;
}
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 `
<div class="funds-tx-empty">
<p>Transaction history is not available in demo mode.</p>
</div>`;
}
if (!this.txLoaded) {
return '<div class="funds-loading">Loading transactions...</div>';
}
if (this.transactions.length === 0) {
return `
<div class="funds-tx-empty">
<p>No transactions yet for this flow.</p>
</div>`;
}
return `
<div class="funds-tx-list">
${this.transactions.map((tx) => `
<div class="funds-tx">
<div class="funds-tx__icon">${tx.type === "deposit" ? "&#x2B06;" : tx.type === "withdraw" ? "&#x2B07;" : "&#x1F504;"}</div>
<div class="funds-tx__body">
<div class="funds-tx__desc">${this.esc(tx.description || tx.type)}</div>
<div class="funds-tx__meta">
${tx.from ? `From: ${this.esc(tx.from)}` : ""}
${tx.to ? ` &rarr; ${this.esc(tx.to)}` : ""}
</div>
</div>
<div class="funds-tx__amount ${tx.type === "deposit" ? "funds-tx__amount--positive" : "funds-tx__amount--negative"}">
${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
</div>
<div class="funds-tx__time">${this.formatTime(tx.timestamp)}</div>
</div>
`).join("")}
</div>`;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
}
customElements.define("folk-funds-app", FolkFundsApp);