From 05fc9d142a19acea140333a01ddaac1df9ca8d45 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Feb 2026 19:18:01 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20restructure=20rFunds=20=E2=80=94=20land?= =?UTF-8?q?ing=20page=20+=20multi-view=20TBFF=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rfunds.online now shows a landing page with TBFF info and flow list instead of the river demo. The river is one tab in a 3-tab flow detail view (Table | River | Transactions). - Add folk-funds-app.ts: main app component with landing + detail views - Extract mapFlowToNodes to shared lib/map-flow.ts - Simplify folk-budget-river.ts to pure renderer (no API fetching) - Restructure routes: / = landing, /demo = demo detail, /flow/:id = flow - Expand funds.css for landing, tabs, table cards, transaction list - Add folk-funds-app.ts build entry to vite.config.ts Co-Authored-By: Claude Opus 4.6 --- modules/funds/components/folk-budget-river.ts | 46 +- modules/funds/components/folk-funds-app.ts | 558 ++++++++++++++++++ modules/funds/components/funds.css | 158 ++++- modules/funds/lib/map-flow.ts | 85 +++ modules/funds/mod.ts | 48 +- modules/funds/standalone.ts | 22 +- vite.config.ts | 37 +- 7 files changed, 921 insertions(+), 33 deletions(-) create mode 100644 modules/funds/components/folk-funds-app.ts create mode 100644 modules/funds/lib/map-flow.ts diff --git a/modules/funds/components/folk-budget-river.ts b/modules/funds/components/folk-budget-river.ts index 8ff8c92..cab45bc 100644 --- a/modules/funds/components/folk-budget-river.ts +++ b/modules/funds/components/folk-budget-river.ts @@ -1,6 +1,7 @@ /** * — animated SVG sankey river visualization. - * Vanilla web component port of rfunds-online BudgetRiver.tsx. + * Pure renderer: receives nodes via setNodes() or falls back to demo data. + * Parent component (folk-funds-app) handles data fetching and mapping. */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types"; @@ -386,6 +387,11 @@ class FolkBudgetRiver extends HTMLElement { private nodes: FlowNode[] = []; private simulating = false; private simTimer: ReturnType | null = null; + private dragging = false; + private dragStartX = 0; + private dragStartY = 0; + private scrollStartX = 0; + private scrollStartY = 0; constructor() { super(); @@ -395,8 +401,10 @@ class FolkBudgetRiver extends HTMLElement { static get observedAttributes() { return ["simulate"]; } connectedCallback() { - this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))]; this.simulating = this.getAttribute("simulate") === "true"; + if (this.nodes.length === 0) { + this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))]; + } this.render(); if (this.simulating) this.startSimulation(); } @@ -437,7 +445,8 @@ class FolkBudgetRiver extends HTMLElement { this.shadow.innerHTML = `
- + ${layout.sourceWaterfalls.map(renderWaterfall).join("")} ${layout.spendingWaterfalls.map(renderWaterfall).join("")} ${layout.overflowBranches.map(renderBranch).join("")} @@ -480,6 +489,35 @@ class FolkBudgetRiver extends HTMLElement { else this.stopSimulation(); this.render(); }); + + // Drag-to-pan + const container = this.shadow.querySelector(".container") as HTMLElement; + if (container) { + container.addEventListener("pointerdown", (e: PointerEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + this.dragging = true; + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.scrollStartX = container.scrollLeft; + this.scrollStartY = container.scrollTop; + container.classList.add("dragging"); + container.setPointerCapture(e.pointerId); + }); + container.addEventListener("pointermove", (e: PointerEvent) => { + if (!this.dragging) return; + container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX); + container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY); + }); + container.addEventListener("pointerup", (e: PointerEvent) => { + this.dragging = false; + container.classList.remove("dragging"); + container.releasePointerCapture(e.pointerId); + }); + + // Auto-center on initial render + container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2; + container.scrollTop = 0; + } } } diff --git a/modules/funds/components/folk-funds-app.ts b/modules/funds/components/folk-funds-app.ts new file mode 100644 index 0000000..6210ad7 --- /dev/null +++ b/modules/funds/components/folk-funds-app.ts @@ -0,0 +1,558 @@ +/** + * — 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 } 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 = "table" | "river" | "transactions"; + +class FolkFundsApp extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private view: View = "landing"; + private tab: Tab = "table"; + 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 res = await fetch(`${base}/api/flows`); + 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"; + + return ` +
+
+

rFunds

+

Token Bonding Flow Funnel

+

+ Distribute resources through cascading funnels with sufficiency-based overflow. + Each funnel fills to its threshold, then excess flows to the next layer — + ensuring every level has enough before abundance cascades forward. +

+ Try the Demo → +
+ +
+

Your Flows

+ ${this.flows.length > 0 ? ` +
+ ${this.flows.map((f) => this.renderFlowCard(f)).join("")} +
+ ` : ` +
+

No flows yet.

+

+ Explore the demo to see how TBFF works. +

+
+ `} +
+ +
+

How TBFF Works

+
+
+
🌊
+

Sources

+

Revenue streams and deposits flow into the system, split across funnels by configurable allocation percentages.

+
+
+
🏛
+

Funnels

+

Budget buckets with min/max thresholds. When a funnel reaches sufficiency, overflow cascades to the next layer.

+
+
+
🎯
+

Outcomes

+

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

+
+
+
+
`; + } + + 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 === "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; + } + + // ─── 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(); + } + } + + private esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } +} + +customElements.define("folk-funds-app", FolkFundsApp); diff --git a/modules/funds/components/funds.css b/modules/funds/components/funds.css index c7e8922..e5818a5 100644 --- a/modules/funds/components/funds.css +++ b/modules/funds/components/funds.css @@ -1,6 +1,162 @@ -/* Funds module theme */ +/* ── Funds module theme ───────────────────────────────── */ body[data-theme="light"] main { background: #0f172a; min-height: calc(100vh - 52px); padding: 0; } + +/* ── Shared utility classes ──────────────────────────── */ +.funds-loading { text-align: center; color: #64748b; padding: 48px 16px; font-size: 14px; } +.funds-error { text-align: center; color: #ef4444; padding: 20px 16px; font-size: 14px; } + +/* ── Landing page ────────────────────────────────────── */ +.funds-landing { max-width: 960px; margin: 0 auto; padding: 24px 20px 64px; } + +.funds-hero { + text-align: center; + padding: 48px 20px 40px; + border-bottom: 1px solid #1e293b; + margin-bottom: 40px; +} +.funds-hero__title { + font-size: 36px; font-weight: 700; margin: 0 0 8px; + background: linear-gradient(135deg, #0ea5e9, #6366f1, #ec4899); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; +} +.funds-hero__subtitle { font-size: 18px; color: #94a3b8; margin: 0 0 16px; font-weight: 500; } +.funds-hero__desc { font-size: 14px; color: #64748b; line-height: 1.7; max-width: 560px; margin: 0 auto 24px; } +.funds-hero__cta { + display: inline-block; padding: 10px 24px; border-radius: 8px; + background: #4f46e5; color: #fff; text-decoration: none; font-weight: 600; font-size: 14px; + transition: background 0.2s; +} +.funds-hero__cta:hover { background: #6366f1; } + +/* Flow list */ +.funds-flows { margin-bottom: 48px; } +.funds-flows__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 16px; } +.funds-flows__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } +.funds-flows__empty { text-align: center; color: #64748b; padding: 32px 16px; font-size: 14px; } +.funds-flows__empty a { color: #6366f1; text-decoration: none; } +.funds-flows__empty a:hover { text-decoration: underline; } + +.funds-flow-card { + display: block; text-decoration: none; + background: #1e293b; border: 1px solid #334155; border-radius: 10px; + padding: 16px; cursor: pointer; transition: border-color 0.2s, transform 0.15s; +} +.funds-flow-card:hover { border-color: #6366f1; transform: translateY(-1px); } +.funds-flow-card__name { font-size: 15px; font-weight: 600; color: #e2e8f0; margin-bottom: 4px; } +.funds-flow-card__value { font-size: 20px; font-weight: 700; color: #0ea5e9; margin-bottom: 4px; } +.funds-flow-card__meta { font-size: 12px; color: #64748b; } + +/* About / how-it-works section */ +.funds-about { margin-bottom: 48px; } +.funds-about__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 16px; } +.funds-about__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; } +.funds-about__card { + background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px; +} +.funds-about__icon { font-size: 28px; margin-bottom: 8px; } +.funds-about__card h3 { font-size: 15px; font-weight: 600; color: #e2e8f0; margin: 0 0 8px; } +.funds-about__card p { font-size: 13px; color: #94a3b8; line-height: 1.6; margin: 0; } + +/* ── Detail view ─────────────────────────────────────── */ +.funds-detail { max-width: 1100px; margin: 0 auto; padding: 16px 20px 64px; } +.funds-detail__header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.funds-detail__back { + color: #64748b; text-decoration: none; font-size: 13px; padding: 4px 0; +} +.funds-detail__back:hover { color: #e2e8f0; } +.funds-detail__title { font-size: 22px; font-weight: 700; color: #e2e8f0; margin: 0; flex: 1; } +.funds-detail__badge { + font-size: 11px; font-weight: 600; color: #fbbf24; background: rgba(251,191,36,0.15); + border: 1px solid rgba(251,191,36,0.3); border-radius: 4px; padding: 2px 8px; +} + +/* ── Tabs ────────────────────────────────────────────── */ +.funds-tabs { + display: flex; gap: 4px; border-bottom: 1px solid #1e293b; margin-bottom: 20px; +} +.funds-tab { + padding: 8px 18px; border: none; border-bottom: 2px solid transparent; + background: transparent; color: #64748b; font-size: 13px; font-weight: 500; + cursor: pointer; transition: color 0.2s, border-color 0.2s; +} +.funds-tab:hover { color: #e2e8f0; } +.funds-tab--active { color: #e2e8f0; border-bottom-color: #6366f1; } + +.funds-tab-content { min-height: 300px; } + +/* ── Table tab — card grid ───────────────────────────── */ +.funds-table { } +.funds-section { margin-bottom: 28px; } +.funds-section__title { font-size: 14px; font-weight: 600; color: #94a3b8; margin: 0 0 12px; text-transform: uppercase; letter-spacing: 0.05em; } +.funds-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } + +.funds-card { + background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 16px; +} +.funds-card__header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } +.funds-card__icon { font-size: 18px; } +.funds-card__label { font-size: 14px; font-weight: 600; color: #e2e8f0; flex: 1; } +.funds-card__type { font-size: 11px; color: #64748b; text-transform: uppercase; } +.funds-card__status { font-size: 11px; font-weight: 600; text-transform: capitalize; } +.funds-card__desc { font-size: 12px; color: #94a3b8; margin-bottom: 10px; line-height: 1.5; } + +.funds-card__stat { margin-bottom: 10px; } +.funds-card__stat-value { font-size: 18px; font-weight: 700; color: #e2e8f0; } +.funds-card__stat-label { font-size: 12px; color: #64748b; margin-left: 4px; } +.funds-card__stats { display: flex; justify-content: space-between; margin-bottom: 8px; } + +/* Progress bar */ +.funds-card__bar-container { + position: relative; height: 6px; background: #334155; border-radius: 3px; + margin-bottom: 10px; overflow: visible; +} +.funds-card__bar { + height: 100%; border-radius: 3px; background: #0ea5e9; + transition: width 0.3s ease; +} +.funds-card__bar--outcome { opacity: 0.8; } +.funds-card__bar-threshold { + position: absolute; top: -3px; width: 2px; height: 12px; + background: #fbbf24; border-radius: 1px; +} + +.funds-card__thresholds { + display: flex; gap: 12px; font-size: 11px; color: #64748b; margin-bottom: 8px; +} + +/* Allocation lists */ +.funds-card__allocs { margin-top: 8px; padding-top: 8px; border-top: 1px solid #334155; } +.funds-card__alloc-title { font-size: 11px; color: #64748b; font-weight: 600; margin-bottom: 4px; text-transform: uppercase; } +.funds-card__alloc { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 6px; margin: 2px 0; } +.funds-card__alloc-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; } + +/* Status colors */ +.funds-status--abundant { color: #fbbf24; } +.funds-status--sufficient { color: #10b981; } +.funds-status--seeking { color: #0ea5e9; } +.funds-status--critical { color: #ef4444; } + +/* ── River tab ───────────────────────────────────────── */ +.funds-river-container { min-height: 500px; } + +/* ── Transactions tab ────────────────────────────────── */ +.funds-tx-list { display: flex; flex-direction: column; gap: 4px; } +.funds-tx-empty { text-align: center; color: #64748b; padding: 48px 16px; font-size: 14px; } + +.funds-tx { + display: flex; align-items: center; gap: 12px; padding: 12px 16px; + background: #1e293b; border: 1px solid #334155; border-radius: 8px; +} +.funds-tx__icon { font-size: 16px; flex-shrink: 0; } +.funds-tx__body { flex: 1; min-width: 0; } +.funds-tx__desc { font-size: 13px; color: #e2e8f0; font-weight: 500; } +.funds-tx__meta { font-size: 11px; color: #64748b; margin-top: 2px; } +.funds-tx__amount { font-size: 14px; font-weight: 600; white-space: nowrap; } +.funds-tx__amount--positive { color: #10b981; } +.funds-tx__amount--negative { color: #ef4444; } +.funds-tx__time { font-size: 11px; color: #64748b; white-space: nowrap; } diff --git a/modules/funds/lib/map-flow.ts b/modules/funds/lib/map-flow.ts new file mode 100644 index 0000000..801666f --- /dev/null +++ b/modules/funds/lib/map-flow.ts @@ -0,0 +1,85 @@ +/** + * Maps TBFF API response data to FlowNode[] for visualization. + * Shared between folk-funds-app (data loading) and folk-budget-river (rendering). + */ + +import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "./types"; + +const SPENDING_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#06b6d4", "#10b981", "#6366f1"]; +const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc2626", "#ea580c"]; + +export function mapFlowToNodes(apiData: any): FlowNode[] { + const nodes: FlowNode[] = []; + + // Map sources (income/deposit streams) + if (apiData.sources) { + for (const src of apiData.sources) { + nodes.push({ + id: src.id, + type: "source", + position: { x: 0, y: 0 }, + data: { + label: src.label || src.name || "Source", + flowRate: src.flowRate ?? src.amount ?? 0, + sourceType: src.sourceType || "recurring", + targetAllocations: (src.targetAllocations || src.allocations || []).map((a: any, i: number) => ({ + targetId: a.targetId, + percentage: a.percentage, + color: a.color || SPENDING_COLORS[i % SPENDING_COLORS.length], + })), + } as SourceNodeData, + }); + } + } + + // Map funnels (budget buckets) + if (apiData.funnels) { + for (const funnel of apiData.funnels) { + nodes.push({ + id: funnel.id, + type: "funnel", + position: { x: 0, y: 0 }, + data: { + label: funnel.label || funnel.name || "Funnel", + currentValue: funnel.currentValue ?? funnel.balance ?? 0, + minThreshold: funnel.minThreshold ?? 0, + maxThreshold: funnel.maxThreshold ?? funnel.currentValue ?? 10000, + maxCapacity: funnel.maxCapacity ?? funnel.maxThreshold ?? 100000, + inflowRate: funnel.inflowRate ?? 0, + sufficientThreshold: funnel.sufficientThreshold, + dynamicOverflow: funnel.dynamicOverflow ?? false, + overflowAllocations: (funnel.overflowAllocations || []).map((a: any, i: number) => ({ + targetId: a.targetId, + percentage: a.percentage, + color: a.color || OVERFLOW_COLORS[i % OVERFLOW_COLORS.length], + })), + spendingAllocations: (funnel.spendingAllocations || []).map((a: any, i: number) => ({ + targetId: a.targetId, + percentage: a.percentage, + color: a.color || SPENDING_COLORS[i % SPENDING_COLORS.length], + })), + } as FunnelNodeData, + }); + } + } + + // Map outcomes (funding targets) + if (apiData.outcomes) { + for (const outcome of apiData.outcomes) { + nodes.push({ + id: outcome.id, + type: "outcome", + position: { x: 0, y: 0 }, + data: { + label: outcome.label || outcome.name || "Outcome", + description: outcome.description || "", + fundingReceived: outcome.fundingReceived ?? outcome.received ?? 0, + fundingTarget: outcome.fundingTarget ?? outcome.target ?? 0, + status: outcome.status || "not-started", + } as OutcomeNodeData, + }); + } + } + + return nodes; +} diff --git a/modules/funds/mod.ts b/modules/funds/mod.ts index 9ef978d..951d1b7 100644 --- a/modules/funds/mod.ts +++ b/modules/funds/mod.ts @@ -93,19 +93,57 @@ routes.get("/api/flows/:flowId/transactions", async (c) => { return c.json(await res.json(), res.status as any); }); -// ─── Page route ───────────────────────────────────────── +// ─── Page routes ──────────────────────────────────────── +const fundsScripts = ` + + `; + +const fundsStyles = ``; + +// Landing page — TBFF info + flow list routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ - title: `${spaceSlug} — Funds | rSpace`, + title: `rFunds — TBFF Flow Funding | rSpace`, moduleId: "funds", spaceSlug, modules: getModuleInfoList(), theme: "light", - styles: ``, - body: ``, - scripts: ``, + styles: fundsStyles, + body: ``, + scripts: fundsScripts, + })); +}); + +// Demo mode — hardcoded demo data, no API needed +routes.get("/demo", (c) => { + const spaceSlug = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `TBFF Demo — rFunds | rSpace`, + moduleId: "funds", + spaceSlug, + modules: getModuleInfoList(), + theme: "light", + styles: fundsStyles, + body: ``, + scripts: fundsScripts, + })); +}); + +// Flow detail — specific flow from API +routes.get("/flow/:flowId", (c) => { + const spaceSlug = c.req.param("space") || "demo"; + const flowId = c.req.param("flowId"); + return c.html(renderShell({ + title: `Flow — rFunds | rSpace`, + moduleId: "funds", + spaceSlug, + modules: getModuleInfoList(), + theme: "light", + styles: fundsStyles, + body: ``, + scripts: fundsScripts, })); }); diff --git a/modules/funds/standalone.ts b/modules/funds/standalone.ts index af49ea3..6ff0eab 100644 --- a/modules/funds/standalone.ts +++ b/modules/funds/standalone.ts @@ -25,19 +25,19 @@ Bun.serve({ port: PORT, async fetch(req) { const url = new URL(req.url); - if (url.pathname !== "/" && !url.pathname.startsWith("/api/")) { - const assetPath = url.pathname.slice(1); - if (assetPath.includes(".")) { - const file = Bun.file(resolve(DIST_DIR, assetPath)); - if (await file.exists()) { - const ct = assetPath.endsWith(".js") ? "application/javascript" : - assetPath.endsWith(".css") ? "text/css" : - assetPath.endsWith(".html") ? "text/html" : - "application/octet-stream"; - return new Response(file, { headers: { "Content-Type": ct } }); - } + // Serve static assets (JS, CSS, etc.) from dist/ + const assetPath = url.pathname.slice(1); + if (assetPath.includes(".")) { + const file = Bun.file(resolve(DIST_DIR, assetPath)); + if (await file.exists()) { + const ct = assetPath.endsWith(".js") ? "application/javascript" : + assetPath.endsWith(".css") ? "text/css" : + assetPath.endsWith(".html") ? "text/html" : + "application/octet-stream"; + return new Response(file, { headers: { "Content-Type": ct } }); } } + // All other routes (/, /demo, /flow/:id, /api/*) handled by Hono return app.fetch(req); }, }); diff --git a/vite.config.ts b/vite.config.ts index 9db0300..ccfb5de 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -241,17 +241,18 @@ export default defineConfig({ resolve(__dirname, "dist/modules/choices/choices.css"), ); - // Build funds module component + // Build funds module components + const fundsAlias = { + "../lib/types": resolve(__dirname, "modules/funds/lib/types.ts"), + "../lib/simulation": resolve(__dirname, "modules/funds/lib/simulation.ts"), + "../lib/presets": resolve(__dirname, "modules/funds/lib/presets.ts"), + "../lib/map-flow": resolve(__dirname, "modules/funds/lib/map-flow.ts"), + }; + await build({ configFile: false, root: resolve(__dirname, "modules/funds/components"), - resolve: { - alias: { - "../lib/types": resolve(__dirname, "modules/funds/lib/types.ts"), - "../lib/simulation": resolve(__dirname, "modules/funds/lib/simulation.ts"), - "../lib/presets": resolve(__dirname, "modules/funds/lib/presets.ts"), - }, - }, + resolve: { alias: fundsAlias }, build: { emptyOutDir: false, outDir: resolve(__dirname, "dist/modules/funds"), @@ -260,11 +261,23 @@ export default defineConfig({ formats: ["es"], fileName: () => "folk-budget-river.js", }, - rollupOptions: { - output: { - entryFileNames: "folk-budget-river.js", - }, + rollupOptions: { output: { entryFileNames: "folk-budget-river.js" } }, + }, + }); + + await build({ + configFile: false, + root: resolve(__dirname, "modules/funds/components"), + resolve: { alias: fundsAlias }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/funds"), + lib: { + entry: resolve(__dirname, "modules/funds/components/folk-funds-app.ts"), + formats: ["es"], + fileName: () => "folk-funds-app.js", }, + rollupOptions: { output: { entryFileNames: "folk-funds-app.js" } }, }, });