diff --git a/modules/choices/components/choices.css b/modules/choices/components/choices.css new file mode 100644 index 0000000..8d131b7 --- /dev/null +++ b/modules/choices/components/choices.css @@ -0,0 +1,6 @@ +/* Choices module theme */ +body[data-theme="light"] main { + background: #0f172a; + min-height: calc(100vh - 52px); + padding: 0; +} diff --git a/modules/choices/components/folk-choices-dashboard.ts b/modules/choices/components/folk-choices-dashboard.ts new file mode 100644 index 0000000..d5af20b --- /dev/null +++ b/modules/choices/components/folk-choices-dashboard.ts @@ -0,0 +1,126 @@ +/** + * — lists choice shapes (polls, rankings, spider charts) + * from the current space and links to the canvas to create/interact with them. + */ + +class FolkChoicesDashboard extends HTMLElement { + private shadow: ShadowRoot; + private choices: any[] = []; + private loading = true; + private space: string; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + this.space = this.getAttribute("space") || "demo"; + } + + connectedCallback() { + this.render(); + this.loadChoices(); + } + + private getApiBase(): string { + const path = window.location.pathname; + const parts = path.split("/").filter(Boolean); + return parts.length >= 2 ? `/${parts[0]}/choices` : "/demo/choices"; + } + + private async loadChoices() { + this.loading = true; + this.render(); + try { + const res = await fetch(`${this.getApiBase()}/api/choices`); + const data = await res.json(); + this.choices = data.choices || []; + } catch (e) { + console.error("Failed to load choices:", e); + } + this.loading = false; + this.render(); + } + + private render() { + const typeIcons: Record = { + "folk-choice-vote": "\u2611", + "folk-choice-rank": "\uD83D\uDCCA", + "folk-choice-spider": "\uD83D\uDD78", + }; + const typeLabels: Record = { + "folk-choice-vote": "Poll", + "folk-choice-rank": "Ranking", + "folk-choice-spider": "Spider Chart", + }; + + this.shadow.innerHTML = ` + + +
+

\u2611 Choices

+ +
+ +
+ Choice tools (Polls, Rankings, Spider Charts) live on the collaborative canvas. + Create them there and they'll appear here for quick access. +
+ + ${this.loading ? `
\u23F3 Loading choices...
` : + this.choices.length === 0 ? this.renderEmpty() : this.renderGrid(typeIcons, typeLabels)} + `; + } + + private renderEmpty(): string { + return `
+
\u2611
+

No choices in this space yet.

+

Open the canvas and use the Poll, Rank, or Spider buttons to create one.

+
`; + } + + private renderGrid(icons: Record, labels: Record): string { + return ``; + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; + } +} + +customElements.define("folk-choices-dashboard", FolkChoicesDashboard); diff --git a/modules/choices/mod.ts b/modules/choices/mod.ts new file mode 100644 index 0000000..0ad9eb1 --- /dev/null +++ b/modules/choices/mod.ts @@ -0,0 +1,69 @@ +/** + * Choices module — voting, ranking, and multi-criteria scoring tools. + * + * The folk-choice-* web components live in lib/ (shared with canvas). + * This module provides: + * - A page listing choice shapes in the current space + * - API to query choice shapes from the Automerge store + * - Links to create new choices on the canvas + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import type { RSpaceModule } from "../../shared/module"; +import { getModuleInfoList } from "../../shared/module"; +import { getDocumentData } from "../../server/community-store"; + +const routes = new Hono(); + +// GET /api/choices — list choice shapes in the current space +routes.get("/api/choices", async (c) => { + const space = c.req.param("space") || c.req.query("space") || "demo"; + const docData = getDocumentData(space); + if (!docData?.shapes) { + return c.json({ choices: [], total: 0 }); + } + + const choiceTypes = ["folk-choice-vote", "folk-choice-rank", "folk-choice-spider"]; + const choices: any[] = []; + + for (const [id, shape] of Object.entries(docData.shapes as Record)) { + if (shape.forgotten) continue; + if (choiceTypes.includes(shape.type)) { + choices.push({ + id, + type: shape.type, + title: shape.title || "Untitled", + mode: shape.mode, + optionCount: (shape.options || []).length, + voteCount: (shape.votes || shape.rankings || shape.scores || []).length, + createdAt: shape.createdAt, + }); + } + } + + return c.json({ choices, total: choices.length }); +}); + +// GET / — choices page +routes.get("/", (c) => { + const spaceSlug = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${spaceSlug} — Choices | rSpace`, + moduleId: "choices", + spaceSlug, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const choicesModule: RSpaceModule = { + id: "choices", + name: "rChoices", + icon: "☑", + description: "Polls, rankings, and multi-criteria scoring", + routes, +}; diff --git a/modules/choices/standalone.ts b/modules/choices/standalone.ts new file mode 100644 index 0000000..ee08863 --- /dev/null +++ b/modules/choices/standalone.ts @@ -0,0 +1,45 @@ +/** + * Choices standalone server — independent deployment at choices.jeffemmett.com. + */ + +import { Hono } from "hono"; +import { resolve } from "node:path"; +import { choicesModule } from "./mod"; + +const PORT = Number(process.env.PORT) || 3000; +const DIST_DIR = resolve(import.meta.dir, "../../dist"); + +const app = new Hono(); + +app.get("/.well-known/webauthn", (c) => { + return c.json( + { origins: ["https://rspace.online"] }, + 200, + { "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" } + ); +}); + +app.route("/", choicesModule.routes); + +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 } }); + } + } + } + return app.fetch(req); + }, +}); + +console.log(`rChoices standalone server running on http://localhost:${PORT}`); diff --git a/modules/funds/components/folk-budget-river.ts b/modules/funds/components/folk-budget-river.ts new file mode 100644 index 0000000..8ff8c92 --- /dev/null +++ b/modules/funds/components/folk-budget-river.ts @@ -0,0 +1,486 @@ +/** + * — animated SVG sankey river visualization. + * Vanilla web component port of rfunds-online BudgetRiver.tsx. + */ + +import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types"; +import { computeSufficiencyState, computeSystemSufficiency, simulateTick, DEFAULT_CONFIG } from "../lib/simulation"; +import { demoNodes } from "../lib/presets"; + +// ─── Layout types ─────────────────────────────────────── + +interface RiverLayout { + sources: SourceLayout[]; + funnels: FunnelLayout[]; + outcomes: OutcomeLayout[]; + sourceWaterfalls: WaterfallLayout[]; + overflowBranches: BranchLayout[]; + spendingWaterfalls: WaterfallLayout[]; + width: number; + height: number; +} + +interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; } +interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; riverWidth: number; segmentLength: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; } +interface OutcomeLayout { id: string; label: string; data: OutcomeNodeData; x: number; y: number; poolWidth: number; fillPercent: number; } +interface WaterfallLayout { id: string; sourceId: string; targetId: string; label: string; percentage: number; x: number; xSource: number; yStart: number; yEnd: number; width: number; riverEndWidth: number; farEndWidth: number; direction: "inflow" | "outflow"; color: string; flowAmount: number; } +interface BranchLayout { sourceId: string; targetId: string; percentage: number; x1: number; y1: number; x2: number; y2: number; width: number; color: string; } + +// ─── Constants ─────────────────────────────────────────── + +const LAYER_HEIGHT = 160; +const WATERFALL_HEIGHT = 120; +const GAP = 40; +const MIN_RIVER_WIDTH = 24; +const MAX_RIVER_WIDTH = 100; +const MIN_WATERFALL_WIDTH = 4; +const SEGMENT_LENGTH = 200; +const POOL_WIDTH = 100; +const POOL_HEIGHT = 60; +const SOURCE_HEIGHT = 40; + +const COLORS = { + sourceWaterfall: "#10b981", + riverHealthy: ["#0ea5e9", "#06b6d4"], + riverOverflow: ["#f59e0b", "#fbbf24"], + riverCritical: ["#ef4444", "#f87171"], + riverSufficient: ["#fbbf24", "#10b981"], + overflowBranch: "#f59e0b", + spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"], + outcomePool: "#3b82f6", + goldenGlow: "#fbbf24", + bg: "#0f172a", + text: "#e2e8f0", + textMuted: "#94a3b8", +}; + +function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] { + const totalPct = percentages.reduce((s, p) => s + p, 0); + if (totalPct === 0) return percentages.map(() => minWidth); + let widths = percentages.map((p) => (p / totalPct) * totalAvailable); + const belowMin = widths.filter((w) => w < minWidth); + if (belowMin.length > 0 && belowMin.length < widths.length) { + const deficit = belowMin.reduce((s, w) => s + (minWidth - w), 0); + const aboveMinTotal = widths.filter((w) => w >= minWidth).reduce((s, w) => s + w, 0); + widths = widths.map((w) => { + if (w < minWidth) return minWidth; + return Math.max(minWidth, w - (w / aboveMinTotal) * deficit); + }); + } + return widths; +} + +// ─── Layout engine (faithful port) ────────────────────── + +function computeLayout(nodes: FlowNode[]): RiverLayout { + const funnelNodes = nodes.filter((n) => n.type === "funnel"); + const outcomeNodes = nodes.filter((n) => n.type === "outcome"); + const sourceNodes = nodes.filter((n) => n.type === "source"); + + const overflowTargets = new Set(); + const spendingTargets = new Set(); + + funnelNodes.forEach((n) => { + const data = n.data as FunnelNodeData; + data.overflowAllocations?.forEach((a) => overflowTargets.add(a.targetId)); + data.spendingAllocations?.forEach((a) => spendingTargets.add(a.targetId)); + }); + + const rootFunnels = funnelNodes.filter((n) => !overflowTargets.has(n.id)); + + const funnelLayers = new Map(); + rootFunnels.forEach((n) => funnelLayers.set(n.id, 0)); + + const queue = [...rootFunnels]; + while (queue.length > 0) { + const current = queue.shift()!; + const data = current.data as FunnelNodeData; + const parentLayer = funnelLayers.get(current.id) ?? 0; + data.overflowAllocations?.forEach((a) => { + const child = funnelNodes.find((n) => n.id === a.targetId); + if (child && !funnelLayers.has(child.id)) { + funnelLayers.set(child.id, parentLayer + 1); + queue.push(child); + } + }); + } + + const layerGroups = new Map(); + funnelNodes.forEach((n) => { + const layer = funnelLayers.get(n.id) ?? 0; + if (!layerGroups.has(layer)) layerGroups.set(layer, []); + layerGroups.get(layer)!.push(n); + }); + + const maxLayer = Math.max(...Array.from(layerGroups.keys()), 0); + const sourceLayerY = GAP; + const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP; + + const funnelLayouts: FunnelLayout[] = []; + + for (let layer = 0; layer <= maxLayer; layer++) { + const layerNodes = layerGroups.get(layer) || []; + const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP); + const totalWidth = layerNodes.length * SEGMENT_LENGTH + (layerNodes.length - 1) * GAP * 2; + + layerNodes.forEach((n, i) => { + const data = n.data as FunnelNodeData; + const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1)); + const riverWidth = MIN_RIVER_WIDTH + fillRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH); + const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2); + const status: "healthy" | "overflow" | "critical" = + data.currentValue > data.maxThreshold ? "overflow" : + data.currentValue < data.minThreshold ? "critical" : "healthy"; + + funnelLayouts.push({ id: n.id, label: data.label, data, x, y: layerY, riverWidth, segmentLength: SEGMENT_LENGTH, layer, status, sufficiency: computeSufficiencyState(data) }); + }); + } + + // Source layouts + const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => { + const data = n.data as SourceNodeData; + const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP; + return { id: n.id, label: data.label, flowRate: data.flowRate, x: -totalWidth / 2 + i * (120 + GAP), y: sourceLayerY, width: 120 }; + }); + + // Source waterfalls + const inflowsByFunnel = new Map(); + sourceNodes.forEach((sn) => { + const data = sn.data as SourceNodeData; + data.targetAllocations?.forEach((alloc, i) => { + const flowAmount = (alloc.percentage / 100) * data.flowRate; + if (!inflowsByFunnel.has(alloc.targetId)) inflowsByFunnel.set(alloc.targetId, []); + inflowsByFunnel.get(alloc.targetId)!.push({ sourceNodeId: sn.id, allocIndex: i, flowAmount, percentage: alloc.percentage }); + }); + }); + + const sourceWaterfalls: WaterfallLayout[] = []; + sourceNodes.forEach((sn) => { + const data = sn.data as SourceNodeData; + const sourceLayout = sourceLayouts.find((s) => s.id === sn.id); + if (!sourceLayout) return; + data.targetAllocations?.forEach((alloc, allocIdx) => { + const targetLayout = funnelLayouts.find((f) => f.id === alloc.targetId); + if (!targetLayout) return; + const flowAmount = (alloc.percentage / 100) * data.flowRate; + const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || []; + const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0); + const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1; + const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth); + const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8); + const myIndex = allInflowsToTarget.findIndex((i) => i.sourceNodeId === sn.id && i.allocIndex === allocIdx); + const inflowWidths = distributeWidths(allInflowsToTarget.map((i) => i.flowAmount), targetLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH); + const startX = targetLayout.x + targetLayout.segmentLength * 0.15; + let offsetX = 0; + for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k]; + const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2; + const sourceCenterX = sourceLayout.x + sourceLayout.width / 2; + sourceWaterfalls.push({ id: `src-wf-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: sourceCenterX, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "inflow", color: COLORS.sourceWaterfall, flowAmount }); + }); + }); + + // Implicit waterfalls for root funnels without source nodes + if (sourceNodes.length === 0) { + rootFunnels.forEach((rn) => { + const data = rn.data as FunnelNodeData; + if (data.inflowRate <= 0) return; + const layout = funnelLayouts.find((f) => f.id === rn.id); + if (!layout) return; + sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: "implicit", targetId: rn.id, label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: layout.x + layout.segmentLength / 2, xSource: layout.x + layout.segmentLength / 2, yStart: GAP, yEnd: layout.y, width: layout.riverWidth, riverEndWidth: layout.riverWidth, farEndWidth: Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4), direction: "inflow", color: COLORS.sourceWaterfall, flowAmount: data.inflowRate }); + }); + } + + // Overflow branches + const overflowBranches: BranchLayout[] = []; + funnelNodes.forEach((n) => { + const data = n.data as FunnelNodeData; + const parentLayout = funnelLayouts.find((f) => f.id === n.id); + if (!parentLayout) return; + data.overflowAllocations?.forEach((alloc) => { + const childLayout = funnelLayouts.find((f) => f.id === alloc.targetId); + if (!childLayout) return; + const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth); + overflowBranches.push({ sourceId: n.id, targetId: alloc.targetId, percentage: alloc.percentage, x1: parentLayout.x + parentLayout.segmentLength, y1: parentLayout.y + parentLayout.riverWidth / 2, x2: childLayout.x, y2: childLayout.y + childLayout.riverWidth / 2, width, color: alloc.color || COLORS.overflowBranch }); + }); + }); + + // Outcome layouts + const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT; + const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP; + const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => { + const data = n.data as OutcomeNodeData; + const fillPercent = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0; + return { id: n.id, label: data.label, data, x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP), y: outcomeY, poolWidth: POOL_WIDTH, fillPercent }; + }); + + // Spending waterfalls + const spendingWaterfalls: WaterfallLayout[] = []; + funnelNodes.forEach((n) => { + const data = n.data as FunnelNodeData; + const parentLayout = funnelLayouts.find((f) => f.id === n.id); + if (!parentLayout) return; + const allocations = data.spendingAllocations || []; + if (allocations.length === 0) return; + const percentages = allocations.map((a) => a.percentage); + const slotWidths = distributeWidths(percentages, parentLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH); + const riverEndWidths = distributeWidths(percentages, parentLayout.riverWidth, MIN_WATERFALL_WIDTH); + const startX = parentLayout.x + parentLayout.segmentLength * 0.15; + let offsetX = 0; + allocations.forEach((alloc, i) => { + const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId); + if (!outcomeLayout) return; + const riverEndWidth = riverEndWidths[i]; + const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6); + const riverCenterX = startX + offsetX + slotWidths[i] / 2; + offsetX += slotWidths[i]; + const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2; + spendingWaterfalls.push({ id: `spend-wf-${n.id}-${alloc.targetId}`, sourceId: n.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: poolCenterX, yStart: parentLayout.y + parentLayout.riverWidth + 4, yEnd: outcomeLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "outflow", color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1) }); + }); + }); + + // Compute bounds and normalize + const allX = [...funnelLayouts.map((f) => f.x), ...funnelLayouts.map((f) => f.x + f.segmentLength), ...outcomeLayouts.map((o) => o.x), ...outcomeLayouts.map((o) => o.x + o.poolWidth), ...sourceLayouts.map((s) => s.x), ...sourceLayouts.map((s) => s.x + s.width)]; + const allY = [...funnelLayouts.map((f) => f.y + f.riverWidth), ...outcomeLayouts.map((o) => o.y + POOL_HEIGHT), sourceLayerY]; + + const minX = Math.min(...allX, -100); + const maxX = Math.max(...allX, 100); + const maxY = Math.max(...allY, 400); + const padding = 80; + + const offsetXGlobal = -minX + padding; + const offsetYGlobal = padding; + + funnelLayouts.forEach((f) => { f.x += offsetXGlobal; f.y += offsetYGlobal; }); + outcomeLayouts.forEach((o) => { o.x += offsetXGlobal; o.y += offsetYGlobal; }); + sourceLayouts.forEach((s) => { s.x += offsetXGlobal; s.y += offsetYGlobal; }); + sourceWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; }); + overflowBranches.forEach((b) => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal; }); + spendingWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; }); + + return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, sourceWaterfalls, overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, height: maxY + offsetYGlobal + padding }; +} + +// ─── SVG Rendering ────────────────────────────────────── + +function renderWaterfall(wf: WaterfallLayout): string { + const isInflow = wf.direction === "inflow"; + const height = wf.yEnd - wf.yStart; + if (height <= 0) return ""; + + const topWidth = isInflow ? wf.farEndWidth : wf.riverEndWidth; + const bottomWidth = isInflow ? wf.riverEndWidth : wf.farEndWidth; + const topCx = isInflow ? wf.xSource : wf.x; + const bottomCx = isInflow ? wf.x : wf.xSource; + + const cpFrac1 = isInflow ? 0.55 : 0.2; + const cpFrac2 = isInflow ? 0.75 : 0.45; + const cpY1 = wf.yStart + height * cpFrac1; + const cpY2 = wf.yStart + height * cpFrac2; + + const tl = topCx - topWidth / 2; + const tr = topCx + topWidth / 2; + const bl = bottomCx - bottomWidth / 2; + const br = bottomCx + bottomWidth / 2; + + const shapePath = `M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd} L ${br} ${wf.yEnd} C ${br} ${cpY2}, ${tr} ${cpY1}, ${tr} ${wf.yStart} Z`; + const clipId = `sankey-clip-${wf.id}`; + const gradId = `sankey-grad-${wf.id}`; + const pathMinX = Math.min(tl, bl) - 5; + const pathMaxW = Math.max(topWidth, bottomWidth) + 10; + + return ` + + + + + + + + + + + + ${[0, 1, 2].map((i) => ``).join("")} + + `; +} + +function renderBranch(b: BranchLayout): string { + const dx = b.x2 - b.x1; + const dy = b.y2 - b.y1; + const cpx = b.x1 + dx * 0.5; + const halfW = b.width / 2; + + return ` + + ${b.percentage}%`; +} + +function renderSource(s: SourceLayout): string { + return ` + + ${esc(s.label)} + $${s.flowRate.toLocaleString()}/mo`; +} + +function renderFunnel(f: FunnelLayout): string { + const colors = f.status === "overflow" ? COLORS.riverOverflow : f.status === "critical" ? COLORS.riverCritical : f.sufficiency === "sufficient" || f.sufficiency === "abundant" ? COLORS.riverSufficient : COLORS.riverHealthy; + const gradId = `river-grad-${f.id}`; + const fillRatio = f.data.currentValue / (f.data.maxCapacity || 1); + const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold; + const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant"; + + return ` + + + + + + + + ${isSufficient ? `` : ""} + + ${[0, 1, 2].map((i) => ``).join("")} + ${esc(f.label)} + $${Math.floor(f.data.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} ${isSufficient ? "\u2728" : ""} + + `; +} + +function renderOutcome(o: OutcomeLayout): string { + const filled = (o.fillPercent / 100) * POOL_HEIGHT; + const color = o.data.status === "completed" ? "#10b981" : o.data.status === "blocked" ? "#ef4444" : "#3b82f6"; + + return ` + + + ${filled > 5 ? `` : ""} + ${esc(o.label)} + ${Math.round(o.fillPercent)}%`; +} + +function renderSufficiencyBadge(score: number, x: number, y: number): string { + const pct = Math.round(score * 100); + const color = pct >= 90 ? COLORS.goldenGlow : pct >= 60 ? "#10b981" : pct >= 30 ? "#f59e0b" : "#ef4444"; + const circumference = 2 * Math.PI * 18; + const dashoffset = circumference * (1 - score); + + return ` + + + + + ${pct}% + ENOUGH + `; +} + +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// ─── Web Component ────────────────────────────────────── + +class FolkBudgetRiver extends HTMLElement { + private shadow: ShadowRoot; + private nodes: FlowNode[] = []; + private simulating = false; + private simTimer: ReturnType | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + static get observedAttributes() { return ["simulate"]; } + + connectedCallback() { + this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))]; + this.simulating = this.getAttribute("simulate") === "true"; + this.render(); + if (this.simulating) this.startSimulation(); + } + + disconnectedCallback() { + this.stopSimulation(); + } + + attributeChangedCallback(name: string, _: string, newVal: string) { + if (name === "simulate") { + this.simulating = newVal === "true"; + if (this.simulating) this.startSimulation(); + else this.stopSimulation(); + } + } + + setNodes(nodes: FlowNode[]) { + this.nodes = nodes; + this.render(); + } + + private startSimulation() { + if (this.simTimer) return; + this.simTimer = setInterval(() => { + this.nodes = simulateTick(this.nodes, DEFAULT_CONFIG); + this.render(); + }, 500); + } + + private stopSimulation() { + if (this.simTimer) { clearInterval(this.simTimer); this.simTimer = null; } + } + + private render() { + const layout = computeLayout(this.nodes); + const score = computeSystemSufficiency(this.nodes); + + this.shadow.innerHTML = ` + +
+ + ${layout.sourceWaterfalls.map(renderWaterfall).join("")} + ${layout.spendingWaterfalls.map(renderWaterfall).join("")} + ${layout.overflowBranches.map(renderBranch).join("")} + ${layout.sources.map(renderSource).join("")} + ${layout.funnels.map(renderFunnel).join("")} + ${layout.outcomes.map(renderOutcome).join("")} + ${renderSufficiencyBadge(score, layout.width - 70, 10)} + +
+ +
+
+
Inflow
+
Healthy
+
Overflow
+
Critical
+
Spending
+
Sufficient
+
+
`; + + this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => { + this.simulating = !this.simulating; + if (this.simulating) this.startSimulation(); + else this.stopSimulation(); + this.render(); + }); + } +} + +customElements.define("folk-budget-river", FolkBudgetRiver); diff --git a/modules/funds/components/funds.css b/modules/funds/components/funds.css new file mode 100644 index 0000000..c7e8922 --- /dev/null +++ b/modules/funds/components/funds.css @@ -0,0 +1,6 @@ +/* Funds module theme */ +body[data-theme="light"] main { + background: #0f172a; + min-height: calc(100vh - 52px); + padding: 0; +} diff --git a/modules/funds/lib/presets.ts b/modules/funds/lib/presets.ts new file mode 100644 index 0000000..3e8f915 --- /dev/null +++ b/modules/funds/lib/presets.ts @@ -0,0 +1,83 @@ +/** + * Demo presets — ported from rfunds-online/lib/presets.ts. + */ + +import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "./types"; + +export const SPENDING_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#06b6d4", "#10b981", "#6366f1"]; +export const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc2626", "#ea580c"]; + +export const demoNodes: FlowNode[] = [ + { + id: "revenue", type: "source", position: { x: 660, y: -200 }, + data: { + label: "Revenue Stream", flowRate: 5000, sourceType: "recurring", + targetAllocations: [{ targetId: "treasury", percentage: 100, color: "#10b981" }], + } as SourceNodeData, + }, + { + id: "treasury", type: "funnel", position: { x: 630, y: 0 }, + data: { + label: "Treasury", currentValue: 85000, minThreshold: 20000, maxThreshold: 70000, + maxCapacity: 100000, inflowRate: 1000, sufficientThreshold: 60000, dynamicOverflow: true, + overflowAllocations: [ + { targetId: "public-goods", percentage: 40, color: OVERFLOW_COLORS[0] }, + { targetId: "research", percentage: 35, color: OVERFLOW_COLORS[1] }, + { targetId: "emergency", percentage: 25, color: OVERFLOW_COLORS[2] }, + ], + spendingAllocations: [ + { targetId: "treasury-ops", percentage: 100, color: SPENDING_COLORS[0] }, + ], + } as FunnelNodeData, + }, + { + id: "public-goods", type: "funnel", position: { x: 170, y: 450 }, + data: { + label: "Public Goods", currentValue: 45000, minThreshold: 15000, maxThreshold: 50000, + maxCapacity: 70000, inflowRate: 400, sufficientThreshold: 42000, + overflowAllocations: [], + spendingAllocations: [ + { targetId: "pg-infra", percentage: 50, color: SPENDING_COLORS[0] }, + { targetId: "pg-education", percentage: 30, color: SPENDING_COLORS[1] }, + { targetId: "pg-tooling", percentage: 20, color: SPENDING_COLORS[2] }, + ], + } as FunnelNodeData, + }, + { + id: "research", type: "funnel", position: { x: 975, y: 450 }, + data: { + label: "Research", currentValue: 28000, minThreshold: 20000, maxThreshold: 45000, + maxCapacity: 60000, inflowRate: 350, sufficientThreshold: 38000, + overflowAllocations: [], + spendingAllocations: [ + { targetId: "research-grants", percentage: 70, color: SPENDING_COLORS[0] }, + { targetId: "research-papers", percentage: 30, color: SPENDING_COLORS[1] }, + ], + } as FunnelNodeData, + }, + { + id: "emergency", type: "funnel", position: { x: 1320, y: 450 }, + data: { + label: "Emergency", currentValue: 12000, minThreshold: 25000, maxThreshold: 60000, + maxCapacity: 80000, inflowRate: 250, sufficientThreshold: 50000, + overflowAllocations: [], + spendingAllocations: [ + { targetId: "emergency-response", percentage: 100, color: SPENDING_COLORS[0] }, + ], + } as FunnelNodeData, + }, + { id: "pg-infra", type: "outcome", position: { x: -50, y: 900 }, + data: { label: "Infrastructure", description: "Core infrastructure development", fundingReceived: 22000, fundingTarget: 30000, status: "in-progress" } as OutcomeNodeData }, + { id: "pg-education", type: "outcome", position: { x: 180, y: 900 }, + data: { label: "Education", description: "Developer education programs", fundingReceived: 12000, fundingTarget: 20000, status: "in-progress" } as OutcomeNodeData }, + { id: "pg-tooling", type: "outcome", position: { x: 410, y: 900 }, + data: { label: "Dev Tooling", description: "Open-source developer tools", fundingReceived: 5000, fundingTarget: 15000, status: "not-started" } as OutcomeNodeData }, + { id: "treasury-ops", type: "outcome", position: { x: 640, y: 900 }, + data: { label: "Treasury Ops", description: "Day-to-day treasury management", fundingReceived: 15000, fundingTarget: 25000, status: "in-progress" } as OutcomeNodeData }, + { id: "research-grants", type: "outcome", position: { x: 870, y: 900 }, + data: { label: "Grants", description: "Academic research grants", fundingReceived: 18000, fundingTarget: 25000, status: "in-progress" } as OutcomeNodeData }, + { id: "research-papers", type: "outcome", position: { x: 1100, y: 900 }, + data: { label: "Papers", description: "Peer-reviewed publications", fundingReceived: 8000, fundingTarget: 10000, status: "in-progress" } as OutcomeNodeData }, + { id: "emergency-response", type: "outcome", position: { x: 1330, y: 900 }, + data: { label: "Response Fund", description: "Rapid response for critical issues", fundingReceived: 5000, fundingTarget: 50000, status: "not-started" } as OutcomeNodeData }, +]; diff --git a/modules/funds/lib/simulation.ts b/modules/funds/lib/simulation.ts new file mode 100644 index 0000000..702578d --- /dev/null +++ b/modules/funds/lib/simulation.ts @@ -0,0 +1,180 @@ +/** + * Flow simulation engine — pure function, no framework dependencies. + * Ported from rfunds-online/lib/simulation.ts. + */ + +import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from "./types"; + +export interface SimulationConfig { + tickDivisor: number; + spendingRateHealthy: number; + spendingRateOverflow: number; + spendingRateCritical: number; +} + +export const DEFAULT_CONFIG: SimulationConfig = { + tickDivisor: 10, + spendingRateHealthy: 0.5, + spendingRateOverflow: 0.8, + spendingRateCritical: 0.1, +}; + +export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState { + const threshold = data.sufficientThreshold ?? data.maxThreshold; + if (data.currentValue >= data.maxCapacity) return "abundant"; + if (data.currentValue >= threshold) return "sufficient"; + return "seeking"; +} + +export function computeNeedWeights( + targetIds: string[], + allNodes: FlowNode[], +): Map { + const nodeMap = new Map(allNodes.map((n) => [n.id, n])); + const needs = new Map(); + + for (const tid of targetIds) { + const node = nodeMap.get(tid); + if (!node) { needs.set(tid, 0); continue; } + + if (node.type === "funnel") { + const d = node.data as FunnelNodeData; + const threshold = d.sufficientThreshold ?? d.maxThreshold; + const need = Math.max(0, 1 - d.currentValue / (threshold || 1)); + needs.set(tid, need); + } else if (node.type === "outcome") { + const d = node.data as OutcomeNodeData; + const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1)); + needs.set(tid, need); + } else { + needs.set(tid, 0); + } + } + + const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0); + const weights = new Map(); + if (totalNeed === 0) { + const equal = targetIds.length > 0 ? 100 / targetIds.length : 0; + targetIds.forEach((id) => weights.set(id, equal)); + } else { + needs.forEach((need, id) => { + weights.set(id, (need / totalNeed) * 100); + }); + } + return weights; +} + +export function computeSystemSufficiency(nodes: FlowNode[]): number { + let sum = 0; + let count = 0; + + for (const node of nodes) { + if (node.type === "funnel") { + const d = node.data as FunnelNodeData; + const threshold = d.sufficientThreshold ?? d.maxThreshold; + sum += Math.min(1, d.currentValue / (threshold || 1)); + count++; + } else if (node.type === "outcome") { + const d = node.data as OutcomeNodeData; + sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1)); + count++; + } + } + + return count > 0 ? sum / count : 0; +} + +export function simulateTick( + nodes: FlowNode[], + config: SimulationConfig = DEFAULT_CONFIG, +): FlowNode[] { + const { tickDivisor, spendingRateHealthy, spendingRateOverflow, spendingRateCritical } = config; + + const funnelNodes = nodes + .filter((n) => n.type === "funnel") + .sort((a, b) => a.position.y - b.position.y); + + const overflowIncoming = new Map(); + const spendingIncoming = new Map(); + const updatedFunnels = new Map(); + + for (const node of funnelNodes) { + const src = node.data as FunnelNodeData; + const data: FunnelNodeData = { ...src }; + + let value = data.currentValue + data.inflowRate / tickDivisor; + value += overflowIncoming.get(node.id) ?? 0; + value = Math.min(value, data.maxCapacity); + + if (value > data.maxThreshold && data.overflowAllocations.length > 0) { + const excess = value - data.maxThreshold; + + if (data.dynamicOverflow) { + const targetIds = data.overflowAllocations.map((a) => a.targetId); + const needWeights = computeNeedWeights(targetIds, nodes); + for (const alloc of data.overflowAllocations) { + const weight = needWeights.get(alloc.targetId) ?? 0; + const share = excess * (weight / 100); + overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); + } + } else { + for (const alloc of data.overflowAllocations) { + const share = excess * (alloc.percentage / 100); + overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); + } + } + value = data.maxThreshold; + } + + if (value > 0 && data.spendingAllocations.length > 0) { + let rateMultiplier: number; + if (value > data.maxThreshold) { + rateMultiplier = spendingRateOverflow; + } else if (value >= data.minThreshold) { + rateMultiplier = spendingRateHealthy; + } else { + rateMultiplier = spendingRateCritical; + } + + let drain = (data.inflowRate / tickDivisor) * rateMultiplier; + drain = Math.min(drain, value); + value -= drain; + + for (const alloc of data.spendingAllocations) { + const share = drain * (alloc.percentage / 100); + spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share); + } + } + + data.currentValue = Math.max(0, value); + updatedFunnels.set(node.id, data); + } + + return nodes.map((node) => { + if (node.type === "funnel" && updatedFunnels.has(node.id)) { + return { ...node, data: updatedFunnels.get(node.id)! }; + } + + if (node.type === "outcome") { + const data = node.data as OutcomeNodeData; + const incoming = spendingIncoming.get(node.id) ?? 0; + if (incoming <= 0) return node; + + const newReceived = Math.min( + data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity, + data.fundingReceived + incoming, + ); + + let newStatus = data.status; + if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== "blocked") { + newStatus = "completed"; + } else if (newReceived > 0 && newStatus === "not-started") { + newStatus = "in-progress"; + } + + return { ...node, data: { ...data, fundingReceived: newReceived, status: newStatus } }; + } + + return node; + }); +} diff --git a/modules/funds/lib/types.ts b/modules/funds/lib/types.ts new file mode 100644 index 0000000..440c815 --- /dev/null +++ b/modules/funds/lib/types.ts @@ -0,0 +1,78 @@ +/** + * Flow types — framework-agnostic (ported from rfunds-online, @xyflow removed). + */ + +export interface IntegrationSource { + type: "rvote" | "safe" | "manual"; + safeAddress?: string; + safeChainId?: number; + tokenAddress?: string | null; + tokenSymbol?: string; + tokenDecimals?: number; + rvoteSpaceSlug?: string; + rvoteProposalId?: string; + rvoteProposalStatus?: string; + rvoteProposalScore?: number; + lastFetchedAt?: number; +} + +export interface OverflowAllocation { + targetId: string; + percentage: number; + color: string; +} + +export interface SpendingAllocation { + targetId: string; + percentage: number; + color: string; +} + +export interface SourceAllocation { + targetId: string; + percentage: number; + color: string; +} + +export type SufficiencyState = "seeking" | "sufficient" | "abundant"; + +export interface FunnelNodeData { + label: string; + currentValue: number; + minThreshold: number; + maxThreshold: number; + maxCapacity: number; + inflowRate: number; + sufficientThreshold?: number; + dynamicOverflow?: boolean; + overflowAllocations: OverflowAllocation[]; + spendingAllocations: SpendingAllocation[]; + source?: IntegrationSource; + [key: string]: unknown; +} + +export interface OutcomeNodeData { + label: string; + description?: string; + fundingReceived: number; + fundingTarget: number; + status: "not-started" | "in-progress" | "completed" | "blocked"; + source?: IntegrationSource; + [key: string]: unknown; +} + +export interface SourceNodeData { + label: string; + flowRate: number; + sourceType: "recurring" | "one-time" | "treasury"; + targetAllocations: SourceAllocation[]; + [key: string]: unknown; +} + +/** Lightweight node (replaces @xyflow/react Node) */ +export interface FlowNode { + id: string; + type: "funnel" | "outcome" | "source"; + position: { x: number; y: number }; + data: FunnelNodeData | OutcomeNodeData | SourceNodeData; +} diff --git a/modules/funds/mod.ts b/modules/funds/mod.ts new file mode 100644 index 0000000..b03a777 --- /dev/null +++ b/modules/funds/mod.ts @@ -0,0 +1,118 @@ +/** + * Funds module — budget flows, river visualization, and treasury management. + * + * Proxies flow-service API calls and serves the BudgetRiver visualization. + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import type { RSpaceModule } from "../../shared/module"; +import { getModuleInfoList } from "../../shared/module"; + +const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010"; + +const routes = new Hono(); + +// ─── Flow Service API proxy ───────────────────────────── +// These proxy to the payment-flow backend so the frontend +// can call them from the same origin. + +routes.get("/api/flows", async (c) => { + const owner = c.req.header("X-Owner-Address") || ""; + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows?owner=${encodeURIComponent(owner)}`); + return c.json(await res.json(), res.status as any); +}); + +routes.get("/api/flows/:flowId", async (c) => { + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}`); + return c.json(await res.json(), res.status as any); +}); + +routes.post("/api/flows", async (c) => { + const body = await c.req.text(); + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + return c.json(await res.json(), res.status as any); +}); + +routes.post("/api/flows/:flowId/deposit", async (c) => { + const body = await c.req.text(); + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/deposit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + return c.json(await res.json(), res.status as any); +}); + +routes.post("/api/flows/:flowId/withdraw", async (c) => { + const body = await c.req.text(); + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/withdraw`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + return c.json(await res.json(), res.status as any); +}); + +routes.post("/api/flows/:flowId/activate", async (c) => { + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/activate`, { method: "POST" }); + return c.json(await res.json(), res.status as any); +}); + +routes.post("/api/flows/:flowId/pause", async (c) => { + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/pause`, { method: "POST" }); + return c.json(await res.json(), res.status as any); +}); + +routes.post("/api/flows/:flowId/funnels", async (c) => { + const body = await c.req.text(); + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/funnels`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + return c.json(await res.json(), res.status as any); +}); + +routes.post("/api/flows/:flowId/outcomes", async (c) => { + const body = await c.req.text(); + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/outcomes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + return c.json(await res.json(), res.status as any); +}); + +routes.get("/api/flows/:flowId/transactions", async (c) => { + const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/transactions`); + return c.json(await res.json(), res.status as any); +}); + +// ─── Page route ───────────────────────────────────────── + +routes.get("/", (c) => { + const spaceSlug = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${spaceSlug} — Funds | rSpace`, + moduleId: "funds", + spaceSlug, + modules: getModuleInfoList(), + theme: "light", + styles: ``, + body: ``, + scripts: ``, + })); +}); + +export const fundsModule: RSpaceModule = { + id: "funds", + name: "rFunds", + icon: "\uD83C\uDF0A", + description: "Budget flows, river visualization, and treasury management", + routes, +}; diff --git a/modules/funds/standalone.ts b/modules/funds/standalone.ts new file mode 100644 index 0000000..af49ea3 --- /dev/null +++ b/modules/funds/standalone.ts @@ -0,0 +1,45 @@ +/** + * Funds standalone server — independent deployment at rfunds.online. + */ + +import { Hono } from "hono"; +import { resolve } from "node:path"; +import { fundsModule } from "./mod"; + +const PORT = Number(process.env.PORT) || 3000; +const DIST_DIR = resolve(import.meta.dir, "../../dist"); + +const app = new Hono(); + +app.get("/.well-known/webauthn", (c) => { + return c.json( + { origins: ["https://rspace.online"] }, + 200, + { "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" } + ); +}); + +app.route("/", fundsModule.routes); + +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 } }); + } + } + } + return app.fetch(req); + }, +}); + +console.log(`rFunds standalone server running on http://localhost:${PORT}`); diff --git a/server/index.ts b/server/index.ts index 3e7cbf8..65aeabb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -44,6 +44,8 @@ import { pubsModule } from "../modules/pubs/mod"; import { cartModule } from "../modules/cart/mod"; import { providersModule } from "../modules/providers/mod"; import { swagModule } from "../modules/swag/mod"; +import { choicesModule } from "../modules/choices/mod"; +import { fundsModule } from "../modules/funds/mod"; import { spaces } from "./spaces"; import { renderShell } from "./shell"; @@ -54,6 +56,8 @@ registerModule(pubsModule); registerModule(cartModule); registerModule(providersModule); registerModule(swagModule); +registerModule(choicesModule); +registerModule(fundsModule); // ── Config ── const PORT = Number(process.env.PORT) || 3000; diff --git a/vite.config.ts b/vite.config.ts index 739eb70..203d94f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -213,6 +213,67 @@ export default defineConfig({ resolve(__dirname, "modules/swag/components/swag.css"), resolve(__dirname, "dist/modules/swag/swag.css"), ); + + // Build choices module component + await build({ + configFile: false, + root: resolve(__dirname, "modules/choices/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/choices"), + lib: { + entry: resolve(__dirname, "modules/choices/components/folk-choices-dashboard.ts"), + formats: ["es"], + fileName: () => "folk-choices-dashboard.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-choices-dashboard.js", + }, + }, + }, + }); + + // Copy choices CSS + mkdirSync(resolve(__dirname, "dist/modules/choices"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/choices/components/choices.css"), + resolve(__dirname, "dist/modules/choices/choices.css"), + ); + + // Build funds module component + 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"), + }, + }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/funds"), + lib: { + entry: resolve(__dirname, "modules/funds/components/folk-budget-river.ts"), + formats: ["es"], + fileName: () => "folk-budget-river.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-budget-river.js", + }, + }, + }, + }); + + // Copy funds CSS + mkdirSync(resolve(__dirname, "dist/modules/funds"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/funds/components/funds.css"), + resolve(__dirname, "dist/modules/funds/funds.css"), + ); }, }, },